gantt-renderer 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.mjs CHANGED
@@ -1,12 +1,12 @@
1
1
  import { z } from "zod";
2
- //#region src/gantt-chart/validation/schemas.ts
2
+ //#region src/lib/validation/schemas.ts
3
3
  const LinkTypeSchema = z.enum([
4
4
  "FS",
5
5
  "SS",
6
6
  "FF",
7
7
  "SF"
8
8
  ]);
9
- const TaskTypeSchema = z.enum([
9
+ const TaskKindSchema = z.enum([
10
10
  "task",
11
11
  "project",
12
12
  "milestone"
@@ -14,45 +14,62 @@ const TaskTypeSchema = z.enum([
14
14
  const SpecialDayKindSchema = z.enum(["holiday", "custom"]);
15
15
  const SpecialDaySchema = z.object({
16
16
  /** ISO date: YYYY-MM-DD */
17
- date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD"),
17
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/u, "Expected YYYY-MM-DD"),
18
18
  kind: SpecialDayKindSchema,
19
19
  label: z.string().min(1).optional(),
20
20
  className: z.string().min(1).optional()
21
21
  });
22
- const TaskSchema = z.object({
22
+ const taskBase = {
23
23
  /** Unique positive integer identifier for the task. */
24
24
  id: z.number().int().positive(),
25
25
  /** Display name / label of the task. */
26
26
  text: z.string().min(1),
27
27
  /** ISO date: YYYY-MM-DD */
28
- startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD"),
29
- /** Duration in hours; 0 = milestone */
30
- durationHours: z.number().int().min(0),
28
+ startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/u, "Expected YYYY-MM-DD"),
31
29
  /** Optional id of the parent task. When set, this task is a child in the hierarchy. */
32
30
  parent: z.number().int().positive().optional(),
33
- /** 0–100 completion percentage (integer) */
31
+ /** Optional CSS color value for the task bar. Overrides the default color assignment. */
32
+ color: z.string().optional(),
33
+ /** When `true`, the task bar cannot be dragged or resized. */
34
+ readonly: z.boolean().optional(),
35
+ /** Optional arbitrary metadata for consumer use. Preserved in the parsed output. */
36
+ data: z.record(z.string(), z.unknown()).optional()
37
+ };
38
+ /** @internal */
39
+ const TaskLeafSchema = z.object({
40
+ ...taskBase,
41
+ kind: z.literal("task"),
42
+ /** Duration in hours. Must be positive; use `kind: 'milestone'` for zero-duration points. */
43
+ durationHours: z.number().int().positive(),
44
+ /** 0–100 completion percentage (integer). */
45
+ percentComplete: z.number().int().min(0).max(100).default(0)
46
+ });
47
+ /** @internal */
48
+ const TaskProjectSchema = z.object({
49
+ ...taskBase,
50
+ kind: z.literal("project"),
51
+ /** Duration in hours. Must be positive; use `kind: 'milestone'` for zero-duration points. */
52
+ durationHours: z.number().int().positive(),
53
+ /** 0–100 completion percentage (integer). */
34
54
  percentComplete: z.number().int().min(0).max(100).default(0),
35
55
  /**
36
- * Task type: `'task'`, `'project'`, or `'milestone'`.
37
- *
38
- * - `'task'` — A regular task with a colored bar.
39
- * - `'project'` — A summary/group row with a colored bar.
40
- * - `'milestone'` — A zero-duration marker rendered as a diamond.
41
- *
42
- * @default 'task'
43
- */
44
- type: TaskTypeSchema.default("task"),
45
- /**
46
56
  * Initial expanded state for tree hierarchy.
47
57
  * When `false`, children of this task are hidden on initial render.
48
- * Only relevant for tasks with child tasks.
49
58
  *
50
59
  * @default true
51
60
  */
52
- open: z.boolean().default(true),
53
- /** Optional CSS color value for the task bar. Overrides the default color assignment. */
54
- color: z.string().optional()
61
+ open: z.boolean().default(true)
62
+ });
63
+ /** @internal */
64
+ const TaskMilestoneSchema = z.object({
65
+ ...taskBase,
66
+ kind: z.literal("milestone")
55
67
  });
68
+ const TaskSchema = z.discriminatedUnion("kind", [
69
+ TaskLeafSchema,
70
+ TaskProjectSchema,
71
+ TaskMilestoneSchema
72
+ ]);
56
73
  const LinkSchema = z.object({
57
74
  /** Unique positive integer identifier for the dependency link. */
58
75
  id: z.number().int().positive(),
@@ -70,13 +87,66 @@ const LinkSchema = z.object({
70
87
  *
71
88
  * @default 'FS'
72
89
  */
73
- type: LinkTypeSchema.default("FS")
90
+ type: LinkTypeSchema.default("FS"),
91
+ /** When `true`, the link cannot be modified or deleted through the UI. */
92
+ readonly: z.boolean().optional(),
93
+ /** Optional arbitrary metadata for consumer use. Preserved in the parsed output. */
94
+ data: z.record(z.string(), z.unknown()).optional()
95
+ }).refine((l) => l.source !== l.target, {
96
+ message: "A link cannot connect a task to itself",
97
+ path: ["target"]
74
98
  });
75
99
  const GanttInputSchema = z.object({
76
100
  /** Array of task objects. At least one task is required. */
77
101
  tasks: z.array(TaskSchema).min(1),
78
102
  /** Optional array of dependency link objects. Defaults to empty array. */
79
103
  links: z.array(LinkSchema).default([])
104
+ }).superRefine((data, ctx) => {
105
+ const taskIds = /* @__PURE__ */ new Set();
106
+ for (let i = 0; i < data.tasks.length; i++) {
107
+ const task = data.tasks[i];
108
+ if (task !== void 0) {
109
+ if (taskIds.has(task.id)) ctx.addIssue({
110
+ code: "custom",
111
+ message: `Duplicate task id: ${task.id}`,
112
+ path: [
113
+ "tasks",
114
+ i,
115
+ "id"
116
+ ]
117
+ });
118
+ taskIds.add(task.id);
119
+ }
120
+ }
121
+ const linkIds = /* @__PURE__ */ new Set();
122
+ for (let i = 0; i < data.links.length; i++) {
123
+ const link = data.links[i];
124
+ if (link !== void 0) {
125
+ if (linkIds.has(link.id)) ctx.addIssue({
126
+ code: "custom",
127
+ message: `Duplicate link id: ${link.id}`,
128
+ path: [
129
+ "links",
130
+ i,
131
+ "id"
132
+ ]
133
+ });
134
+ linkIds.add(link.id);
135
+ }
136
+ }
137
+ const pairKeys = /* @__PURE__ */ new Set();
138
+ for (let i = 0; i < data.links.length; i++) {
139
+ const link = data.links[i];
140
+ if (link !== void 0) {
141
+ const key = `${link.source}:${link.target}`;
142
+ if (pairKeys.has(key)) ctx.addIssue({
143
+ code: "custom",
144
+ message: `Duplicate link pair: source=${link.source} target=${link.target}`,
145
+ path: ["links", i]
146
+ });
147
+ pairKeys.add(key);
148
+ }
149
+ }
80
150
  });
81
151
  /**
82
152
  * Parses raw external data.
@@ -88,18 +158,8 @@ const GanttInputSchema = z.object({
88
158
  function parseGanttInput(raw) {
89
159
  return GanttInputSchema.parse(raw);
90
160
  }
91
- /**
92
- * Parses without throwing; returns `null` on validation failure.
93
- *
94
- * @param raw - The unvalidated input from the consumer.
95
- * @returns The parsed {@link GanttInput} or `null` when the input is invalid.
96
- */
97
- function safeParseGanttInput(raw) {
98
- const result = GanttInputSchema.safeParse(raw);
99
- return result.success ? result.data : null;
100
- }
101
161
  //#endregion
102
- //#region src/gantt-chart/errors.ts
162
+ //#region src/lib/errors.ts
103
163
  /**
104
164
  * Domain-specific error with a machine-readable {@link GanttErrorCode}.
105
165
  */
@@ -116,23 +176,67 @@ var GanttError = class extends Error {
116
176
  }
117
177
  };
118
178
  //#endregion
119
- //#region src/gantt-chart/domain/tree.ts
179
+ //#region src/lib/domain/tree.ts
180
+ function detectParentCycles(tasks) {
181
+ const adj = /* @__PURE__ */ new Map();
182
+ for (const task of tasks) {
183
+ adj.set(task.id, []);
184
+ if (task.parent !== void 0) {
185
+ const parents = adj.get(task.parent);
186
+ if (parents !== void 0) parents.push(task.id);
187
+ }
188
+ }
189
+ const WHITE = 0, GRAY = 1, BLACK = 2;
190
+ const color = /* @__PURE__ */ new Map();
191
+ const parent = /* @__PURE__ */ new Map();
192
+ for (const id of adj.keys()) color.set(id, WHITE);
193
+ const dfs = (u) => {
194
+ color.set(u, GRAY);
195
+ for (const v of adj.get(u) ?? []) {
196
+ const vc = color.get(v) ?? WHITE;
197
+ if (vc === GRAY) {
198
+ const path = [v, u];
199
+ let cur = u;
200
+ while (cur !== v) {
201
+ const p = parent.get(cur);
202
+ if (p === void 0) break;
203
+ path.push(p);
204
+ cur = p;
205
+ }
206
+ throw new GanttError("PARENT_CYCLE", `Parent cycle detected: ${[...path].reverse().join(" -> ")}`);
207
+ }
208
+ if (vc === WHITE) {
209
+ parent.set(v, u);
210
+ dfs(v);
211
+ }
212
+ }
213
+ color.set(u, BLACK);
214
+ };
215
+ for (const id of adj.keys()) if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
216
+ }
120
217
  /**
121
218
  * Builds a typed tree from a flat task array.
122
219
  * Order of tasks[] is irrelevant — parents need not precede children.
123
220
  *
124
221
  * @param tasks - The flat array of tasks to convert into a tree.
125
222
  * @returns Root-level {@link TaskNode} instances with populated `children`.
126
- * @throws {GanttError} When a task references a `parent` id that does not exist.
223
+ * @throws {GanttError} When a task references a `parent` id that does not exist,
224
+ * when a parent cycle is detected, or when a `parent` points to a
225
+ * milestone or leaf task.
127
226
  */
128
227
  function buildTaskTree(tasks) {
129
228
  const map = /* @__PURE__ */ new Map();
130
229
  const roots = [];
230
+ for (const task of tasks) if (task.parent !== void 0) {
231
+ const parentTask = tasks.find((t) => t.id === task.parent);
232
+ if (parentTask !== void 0 && (parentTask.kind === "milestone" || parentTask.kind === "task")) throw new GanttError("PARENT_REFERENCE", `Task id=${task.id} cannot have parent id=${task.parent} of kind '${parentTask.kind}'`);
233
+ }
131
234
  for (const task of tasks) map.set(task.id, {
132
235
  ...task,
133
236
  children: [],
134
237
  depth: 0
135
238
  });
239
+ detectParentCycles(tasks);
136
240
  for (const task of tasks) {
137
241
  const node = map.get(task.id);
138
242
  if (node === void 0) continue;
@@ -177,7 +281,7 @@ function isParent(node) {
177
281
  return node.children.length > 0;
178
282
  }
179
283
  //#endregion
180
- //#region src/gantt-chart/domain/dependencies.ts
284
+ //#region src/lib/domain/dependencies.ts
181
285
  /**
182
286
  * Detects circular dependencies in the link graph using DFS tri-colour marking.
183
287
  *
@@ -221,21 +325,34 @@ function detectCycles(tasks, links) {
221
325
  for (const id of adj.keys()) if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
222
326
  }
223
327
  /**
224
- * Validates that every link references existing task IDs.
328
+ * Validates that every link references existing task IDs and that no
329
+ * duplicate (source, target) pairs exist.
225
330
  *
226
331
  * @param tasks - The task list (used as the reference set of valid IDs).
227
332
  * @param links - The dependency links to validate.
228
- * @throws {GanttError} When any link references a non-existent source or target task.
333
+ * @throws {GanttError} When any link references a non-existent source or target task,
334
+ * when a non-FS link connects to/from a milestone, or when duplicate
335
+ * (source, target) pairs exist.
229
336
  */
230
337
  function validateLinkRefs(tasks, links) {
231
338
  const ids = new Set(tasks.map((t) => t.id));
339
+ const taskById = new Map(tasks.map((t) => [t.id, t]));
340
+ const pairKeys = /* @__PURE__ */ new Set();
232
341
  for (const link of links) {
233
342
  if (!ids.has(link.source)) throw new GanttError("LINK_REFERENCE", `Link id=${link.id}: source=${link.source} not found`);
234
343
  if (!ids.has(link.target)) throw new GanttError("LINK_REFERENCE", `Link id=${link.id}: target=${link.target} not found`);
344
+ const pairKey = `${link.source}:${link.target}`;
345
+ if (pairKeys.has(pairKey)) throw new GanttError("DUPLICATE_LINK_PAIR", `Link id=${link.id}: duplicate pair source=${link.source} target=${link.target}`);
346
+ pairKeys.add(pairKey);
347
+ if (link.type !== "FS") {
348
+ const sourceTask = taskById.get(link.source);
349
+ const targetTask = taskById.get(link.target);
350
+ if (sourceTask?.kind === "milestone" || targetTask?.kind === "milestone") throw new GanttError("MILESTONE_LINK_TYPE", `Link id=${link.id}: non-FS type '${link.type}' not allowed when connected to a milestone`);
351
+ }
235
352
  }
236
353
  }
237
354
  //#endregion
238
- //#region src/gantt-chart/locale.ts
355
+ //#region src/lib/locale.ts
239
356
  const WEEK_START_REGION = {
240
357
  US: 0,
241
358
  CA: 0,
@@ -517,7 +634,7 @@ function formatLabel(template, arg) {
517
634
  return template.replaceAll("{0}", arg);
518
635
  }
519
636
  //#endregion
520
- //#region src/gantt-chart/domain/dateMath.ts
637
+ //#region src/lib/domain/dateMath.ts
521
638
  /**
522
639
  * Parses `YYYY-MM-DD` → UTC midnight `Date`.
523
640
  *
@@ -657,7 +774,7 @@ function formatDisplayDate(dateStr, locale) {
657
774
  });
658
775
  }
659
776
  //#endregion
660
- //#region src/gantt-chart/timeline/scale.ts
777
+ //#region src/lib/timeline/scale.ts
661
778
  const H = 36e5;
662
779
  const D = 864e5;
663
780
  const SCALE_CONFIGS = {
@@ -740,7 +857,7 @@ function nextScaleBoundary(date, scale) {
740
857
  }
741
858
  }
742
859
  //#endregion
743
- //#region src/gantt-chart/timeline/pixelMapper.ts
860
+ //#region src/lib/timeline/pixelMapper.ts
744
861
  /**
745
862
  * Creates a stateless pixel mapper for the given scale and viewport start.
746
863
  * All conversions are O(1) arithmetic — safe to call in tight loops.
@@ -773,7 +890,7 @@ function createPixelMapper(scale, viewportStart) {
773
890
  };
774
891
  }
775
892
  //#endregion
776
- //#region src/gantt-chart/timeline/layoutEngine.ts
893
+ //#region src/lib/timeline/layoutEngine.ts
777
894
  const DENSITY = {
778
895
  rowHeight: 44,
779
896
  barHeight: 28,
@@ -802,8 +919,7 @@ function computeLayout(rows, mapper) {
802
919
  const x = mapper.toX(start);
803
920
  const y = i * ROW_HEIGHT + BAR_Y_OFFSET;
804
921
  const centerY = i * ROW_HEIGHT + ROW_HEIGHT / 2;
805
- const type = task.type ?? "task";
806
- if (type === "milestone") {
922
+ if (task.kind === "milestone") {
807
923
  result.set(task.id, {
808
924
  taskId: task.id,
809
925
  x,
@@ -811,7 +927,7 @@ function computeLayout(rows, mapper) {
811
927
  width: 0,
812
928
  height: BAR_HEIGHT,
813
929
  progressWidth: 0,
814
- type: "milestone",
930
+ kind: "milestone",
815
931
  rowIndex: i,
816
932
  centerX: x,
817
933
  centerY
@@ -827,7 +943,7 @@ function computeLayout(rows, mapper) {
827
943
  width,
828
944
  height: BAR_HEIGHT,
829
945
  progressWidth,
830
- type,
946
+ kind: task.kind,
831
947
  rowIndex: i,
832
948
  centerX: x + width / 2,
833
949
  centerY
@@ -860,99 +976,245 @@ function deriveViewport(tasks, paddingHours = 48) {
860
976
  let maxMs = -Infinity;
861
977
  for (const task of tasks) {
862
978
  const start = parseDate(task.startDate);
863
- const end = addHours(start, task.durationHours);
864
979
  if (start.getTime() < minMs) minMs = start.getTime();
865
- if (end.getTime() > maxMs) maxMs = end.getTime();
980
+ if (task.kind !== "milestone") {
981
+ const end = addHours(start, task.durationHours);
982
+ if (end.getTime() > maxMs) maxMs = end.getTime();
983
+ } else if (start.getTime() > maxMs) maxMs = start.getTime();
866
984
  }
867
985
  return [addHours(new Date(minMs), -paddingHours), addHours(new Date(maxMs), paddingHours)];
868
986
  }
869
987
  //#endregion
870
- //#region src/gantt-chart/rendering/linkRouter.ts
871
- const TURN_MARGIN = 12;
988
+ //#region src/lib/rendering/linkRouter.ts
989
+ /** px gap before/after bar for routing clearance and arrow approach */
990
+ const TURN_MARGIN = 24;
991
+ /** px vertical offset below the bar row for same-row loop detours */
992
+ const SAME_ROW_DETOUR = 24;
993
+ /** Segments shorter than this (px) are collapsed before stroking */
994
+ const STUB_THRESHOLD = 2;
995
+ /**
996
+ * Removes consecutive points whose Euclidean distance is below {@link STUB_THRESHOLD}.
997
+ * The first point is always kept. This prevents near‑zero‑length segments from
998
+ * appearing as visible stubs near the arrowhead.
999
+ *
1000
+ * @param points - The ordered vertex list.
1001
+ * @returns A filtered copy, guaranteed to contain at least the first point.
1002
+ */
1003
+ function collapseStubs(points) {
1004
+ const out = [];
1005
+ for (const pt of points) {
1006
+ const last = out.at(-1);
1007
+ if (last === void 0 || Math.hypot(pt.x - last.x, pt.y - last.y) >= STUB_THRESHOLD) out.push(pt);
1008
+ }
1009
+ return out;
1010
+ }
1011
+ /**
1012
+ * True when the dependency arrow enters the target on its **left** edge (FS, SS).
1013
+ * False when the arrow enters on the target's **right** edge (FF, SF).
1014
+ *
1015
+ * The arrowhead uses SVG `orient="auto"` so it rotates to match the direction
1016
+ * of the **last** path segment. Therefore:
1017
+ *
1018
+ * - Left-entry → last segment must travel **RIGHT** (penultimate.x < tx).
1019
+ * - Right-entry → last segment must travel **LEFT** (penultimate.x > tx).
1020
+ *
1021
+ * @param type - The link type.
1022
+ * @returns `true` for FS / SS, `false` for FF / SF.
1023
+ */
1024
+ function isLeftEntry(type) {
1025
+ return type === "FS" || type === "SS";
1026
+ }
1027
+ /**
1028
+ * True when the link exits the source bar on its **right** edge (FS, FF).
1029
+ * False when it exits on the **left** edge (SS, SF).
1030
+ *
1031
+ * The first step after the source anchor should move **away** from the bar,
1032
+ * **not** into it. This means:
1033
+ *
1034
+ * - Exit‑right → first horizontal segment goes RIGHT (+TURN_MARGIN).
1035
+ * - Exit‑left → first horizontal segment goes LEFT (-TURN_MARGIN).
1036
+ *
1037
+ * @param type - The link type.
1038
+ * @returns `true` for FS / FF, `false` for SS / SF.
1039
+ */
1040
+ function isExitRight(type) {
1041
+ return type === "FS" || type === "FF";
1042
+ }
872
1043
  /**
873
- * Produces the vertex list for an orthogonal connector between src and tgt.
1044
+ * Computes anchor points for the given link type.
1045
+ *
1046
+ * | Type | Source anchor (`sx`) | Target anchor (`tx`) |
1047
+ * |------|-----------------------------|-------------------------------|
1048
+ * | FS | right edge of source | left edge of target |
1049
+ * | SS | left edge of source | left edge of target |
1050
+ * | FF | right edge of source | right edge of target |
1051
+ * | SF | left edge of source | right edge of target |
874
1052
  *
875
- * Link semantics:
876
- * FS = source.finish target.start (most common)
877
- * SS = source.start → target.start
878
- * FF = source.finish → target.finish
879
- * SF = source.start → target.finish
1053
+ * Milestone offsets are applied automatically: ± {@link MILESTONE_HALF} replaces
1054
+ * ± width for zero‑width milestones.
880
1055
  *
881
1056
  * @param type - The link type determining start/end anchor points.
882
- * @param src - The source bar layout.
883
- * @param tgt - The target bar layout.
884
- * @returns An ordered array of `Point` vertices.
1057
+ * @param src - The source bar layout.
1058
+ * @param tgt - The target bar layout.
1059
+ * @returns Anchor x coordinates `{sx, tx}`.
1060
+ * @throws {Error} if the link type is not handled (exhaustiveness guard).
885
1061
  */
886
- function route(type, src, tgt) {
887
- let sx, tx;
888
- const sy = src.centerY;
889
- const ty = tgt.centerY;
1062
+ function getAnchors(type, src, tgt) {
1063
+ const srcRight = src.kind === "milestone" ? src.x + MILESTONE_HALF : src.x + src.width;
1064
+ const srcLeft = src.kind === "milestone" ? src.x - MILESTONE_HALF : src.x;
1065
+ const tgtRight = tgt.kind === "milestone" ? tgt.x + MILESTONE_HALF : tgt.x + tgt.width;
1066
+ const tgtLeft = tgt.kind === "milestone" ? tgt.x - MILESTONE_HALF : tgt.x;
890
1067
  switch (type) {
891
- case "FS":
892
- sx = src.type === "milestone" ? src.x + MILESTONE_HALF : src.x + src.width;
893
- tx = tgt.type === "milestone" ? tgt.x - MILESTONE_HALF : tgt.x;
894
- break;
895
- case "SS":
896
- sx = src.type === "milestone" ? src.x - MILESTONE_HALF : src.x;
897
- tx = tgt.type === "milestone" ? tgt.x - MILESTONE_HALF : tgt.x;
898
- break;
899
- case "FF":
900
- sx = src.type === "milestone" ? src.x + MILESTONE_HALF : src.x + src.width;
901
- tx = tgt.type === "milestone" ? tgt.x + MILESTONE_HALF : tgt.x + tgt.width;
902
- break;
903
- case "SF":
904
- sx = src.type === "milestone" ? src.x - MILESTONE_HALF : src.x;
905
- tx = tgt.type === "milestone" ? tgt.x + MILESTONE_HALF : tgt.x + tgt.width;
906
- break;
907
- }
908
- if (Math.abs(sy - ty) < 1) return [{
909
- x: sx,
910
- y: sy
911
- }, {
912
- x: tx,
913
- y: ty
914
- }];
915
- if (sx <= tx) {
916
- const midX = sx + Math.max(TURN_MARGIN, (tx - sx) / 2);
917
- return [
918
- {
919
- x: sx,
920
- y: sy
921
- },
922
- {
923
- x: midX,
924
- y: sy
925
- },
926
- {
927
- x: midX,
928
- y: ty
929
- },
930
- {
931
- x: tx,
932
- y: ty
933
- }
934
- ];
1068
+ case "FS": return {
1069
+ sx: srcRight,
1070
+ tx: tgtLeft
1071
+ };
1072
+ case "SS": return {
1073
+ sx: srcLeft,
1074
+ tx: tgtLeft
1075
+ };
1076
+ case "FF": return {
1077
+ sx: srcRight,
1078
+ tx: tgtRight
1079
+ };
1080
+ case "SF": return {
1081
+ sx: srcLeft,
1082
+ tx: tgtRight
1083
+ };
1084
+ default: throw new Error(`Unhandled link type: ${String(type)}`);
1085
+ }
1086
+ }
1087
+ /**
1088
+ * Routes a link whose source and target rows are within 1 px of each other.
1089
+ *
1090
+ * **Direct‑line optimisation**
1091
+ * A plain horizontal segment is only used when it is non‑degenerate (`sx ≠ tx`)
1092
+ * AND the arrowhead direction is visually correct:
1093
+ *
1094
+ * | Entry side | Condition | Arrow direction |
1095
+ * |-----------|-----------|----------------|
1096
+ * | left | `sx < tx` | → RIGHT ✓ |
1097
+ * | right | `sx > tx` | ← LEFT ✓ |
1098
+ *
1099
+ * Otherwise a 6‑vertex detour is drawn so that the last segment approaches
1100
+ * the target from the correct side. By default the detour goes **below** the
1101
+ * bars; pass `above = true` when headroom is insufficient below.
1102
+ *
1103
+ * @param sx - Source anchor x.
1104
+ * @param sy - Source row center y.
1105
+ * @param tx - Target anchor x.
1106
+ * @param ty - Target row center y.
1107
+ * @param leftEntry - Whether the link enters the target on its left edge.
1108
+ * @param exitRight - Whether the link exits the source on its right edge.
1109
+ * @param above - Route the detour above the bar row instead of below (default `false`).
1110
+ * @returns An ordered array of {@link Point} vertices.
1111
+ */
1112
+ function routeSameRow(sx, sy, tx, ty, leftEntry, exitRight, above = false) {
1113
+ if (Math.abs(sx - tx) >= STUB_THRESHOLD) {
1114
+ if (leftEntry && sx < tx || !leftEntry && sx > tx) return [{
1115
+ x: sx,
1116
+ y: sy
1117
+ }, {
1118
+ x: tx,
1119
+ y: ty
1120
+ }];
935
1121
  }
936
- const loopX = tx - TURN_MARGIN;
1122
+ const exitDir = exitRight ? TURN_MARGIN : -TURN_MARGIN;
1123
+ const detourY = sy + (above ? -SAME_ROW_DETOUR : SAME_ROW_DETOUR);
1124
+ const approachX = leftEntry ? tx - TURN_MARGIN : tx + TURN_MARGIN;
1125
+ return [
1126
+ {
1127
+ x: sx,
1128
+ y: sy
1129
+ },
1130
+ {
1131
+ x: sx + exitDir,
1132
+ y: sy
1133
+ },
1134
+ {
1135
+ x: sx + exitDir,
1136
+ y: detourY
1137
+ },
1138
+ {
1139
+ x: approachX,
1140
+ y: detourY
1141
+ },
1142
+ {
1143
+ x: approachX,
1144
+ y: ty
1145
+ },
1146
+ {
1147
+ x: tx,
1148
+ y: ty
1149
+ }
1150
+ ];
1151
+ }
1152
+ /**
1153
+ * Routes a link between **different** rows using an orthogonal path.
1154
+ *
1155
+ * 1. Step **away** from the source bar to the crossover x (`crossX`).
1156
+ * 2. Travel **vertically** to the midpoint between rows (`midY`).
1157
+ * 3. Travel **horizontally** to the approach point on the correct side
1158
+ * of the target.
1159
+ * 4. Travel **vertically** to the target row (`ty`).
1160
+ * 5. Final segment to the target entry point (`tx`).
1161
+ *
1162
+ * The crossover x is clamped so the path never doubles back past both bars.
1163
+ * When exit and entry are on the **same** side (SS / FF) the exit-side step
1164
+ * is limited to the approach x, avoiding a wide U‑shape.
1165
+ *
1166
+ * The approach point is chosen so the last segment travels in the arrow
1167
+ * direction demanded by the entry side:
1168
+ *
1169
+ * - Left‑entry (FS, SS): approach from the **left** → `tx - TURN_MARGIN`
1170
+ * Last segment goes RIGHT.
1171
+ * - Right‑entry (FF, SF): approach from the **right** → `tx + TURN_MARGIN`
1172
+ * Last segment goes LEFT.
1173
+ *
1174
+ * left‑entry (FS / SS) right‑entry (FF / SF)
1175
+ * ───────────────────── ─────────────────────
1176
+ * sx ●────────────────► sx ●────────────────►
1177
+ * exitDir exitDir
1178
+ * │ │
1179
+ * │ midY │ midY
1180
+ * ▼ ════════════════════► ▼ ════════════════════►
1181
+ * │ │
1182
+ * │ │
1183
+ * ▼ approachFromLeft ▼ approachFromRight
1184
+ * ●────────────────► ◄────────────────●
1185
+ * tx tx
1186
+ *
1187
+ * @param sx - Source anchor x.
1188
+ * @param sy - Source row center y.
1189
+ * @param tx - Target anchor x.
1190
+ * @param ty - Target row center y.
1191
+ * @param leftEntry - Whether the link enters the target on its left edge.
1192
+ * @param exitRight - Whether the link exits the source on its right edge.
1193
+ * @returns An ordered array of {@link Point} vertices.
1194
+ */
1195
+ function routeMultiRow(sx, sy, tx, ty, leftEntry, exitRight) {
1196
+ const midY = Math.round(Math.abs(sy - ty) / ROW_HEIGHT) % 2 === 0 ? (sy + ty) / 2 + ROW_HEIGHT / 2 : (sy + ty) / 2;
1197
+ const approachX = leftEntry ? tx - TURN_MARGIN : tx + TURN_MARGIN;
1198
+ const crossX = exitRight ? Math.max(sx + TURN_MARGIN, approachX) : Math.min(sx - TURN_MARGIN, approachX);
937
1199
  return [
938
1200
  {
939
1201
  x: sx,
940
1202
  y: sy
941
1203
  },
942
1204
  {
943
- x: sx + TURN_MARGIN,
1205
+ x: crossX,
944
1206
  y: sy
945
1207
  },
946
1208
  {
947
- x: sx + TURN_MARGIN,
948
- y: (sy + ty) / 2
1209
+ x: crossX,
1210
+ y: midY
949
1211
  },
950
1212
  {
951
- x: loopX,
952
- y: (sy + ty) / 2
1213
+ x: approachX,
1214
+ y: midY
953
1215
  },
954
1216
  {
955
- x: loopX,
1217
+ x: approachX,
956
1218
  y: ty
957
1219
  },
958
1220
  {
@@ -962,11 +1224,61 @@ function route(type, src, tgt) {
962
1224
  ];
963
1225
  }
964
1226
  /**
1227
+ * Produces the vertex list for an orthogonal connector between source and target.
1228
+ *
1229
+ * ## Anchor points (sx / tx)
1230
+ *
1231
+ * | Type | Source anchor (`sx`) | Target anchor (`tx`) |
1232
+ * |------|-----------------------------|-------------------------------|
1233
+ * | FS | right edge of source | left edge of target |
1234
+ * | SS | left edge of source | left edge of target |
1235
+ * | FF | right edge of source | right edge of target |
1236
+ * | SF | left edge of source | right edge of target |
1237
+ *
1238
+ * Milestone offsets are applied automatically: ± {@link MILESTONE_HALF} replaces
1239
+ * ± width for zero‑width milestones.
1240
+ *
1241
+ * ## Routing strategy
1242
+ *
1243
+ * **Same row** (|sy − ty| < 1 px):
1244
+ * - Direct horizontal line when non‑degenerate **and** the arrow direction
1245
+ * naturally points **into** the target (see {@link routeSameRow}).
1246
+ * - Otherwise a 6‑vertex detour below the bars is drawn.
1247
+ *
1248
+ * **Different rows**: always a 6‑vertex orthogonal path that steps away from
1249
+ * the source, passes through the midpoint between rows, and approaches the
1250
+ * target from the correct side (see {@link routeMultiRow}).
1251
+ *
1252
+ * ## Arrowhead direction guarantee
1253
+ *
1254
+ * The SVG `marker-end` uses `orient="auto"`, so the arrow rotates to match
1255
+ * the last segment. This function ensures the last segment always travels
1256
+ * **into** the target on the semantically correct edge:
1257
+ *
1258
+ * | Entry side | Target edge | Last segment direction |
1259
+ * |-----------|-------------|-----------------------|
1260
+ * | left | left edge | → RIGHT |
1261
+ * | right | right edge | ← LEFT |
1262
+ *
1263
+ * @param type - The link type determining start/end anchor points.
1264
+ * @param src - The source bar layout.
1265
+ * @param tgt - The target bar layout.
1266
+ * @returns An ordered array of {@link Point} vertices.
1267
+ */
1268
+ function route(type, src, tgt) {
1269
+ const { sx, tx } = getAnchors(type, src, tgt);
1270
+ const sy = src.centerY;
1271
+ const ty = tgt.centerY;
1272
+ const leftEntry = isLeftEntry(type);
1273
+ const exitRight = isExitRight(type);
1274
+ return collapseStubs(Math.abs(sy - ty) < 1 ? routeSameRow(sx, sy, tx, ty, leftEntry, exitRight) : routeMultiRow(sx, sy, tx, ty, leftEntry, exitRight));
1275
+ }
1276
+ /**
965
1277
  * Computes orthogonal routing for all dependency links.
966
1278
  * Links whose source or target is not in the layout map are skipped silently
967
1279
  * (e.g. when the row is collapsed).
968
1280
  *
969
- * @param links - The dependency links to route.
1281
+ * @param links - The dependency links to route.
970
1282
  * @param layouts - A map from task ID to its computed {@link BarLayout}.
971
1283
  * @returns An array of {@link RoutedLink} objects with computed vertex paths.
972
1284
  */
@@ -979,12 +1291,13 @@ function routeLinks(links, layouts) {
979
1291
  linkId: link.id,
980
1292
  sourceTaskId: link.source,
981
1293
  targetTaskId: link.target,
1294
+ type: link.type,
982
1295
  points: route(link.type, src, tgt)
983
1296
  };
984
1297
  }).filter((r) => r !== null);
985
1298
  }
986
1299
  //#endregion
987
- //#region src/gantt-chart/vanilla/dom/helpers.ts
1300
+ //#region src/lib/vanilla/dom/helpers.ts
988
1301
  /**
989
1302
  * Batches style assignments; avoids repeated style recalculations.
990
1303
  *
@@ -1030,7 +1343,7 @@ function setAttrs(elem, attrs) {
1030
1343
  for (const [k, v] of Object.entries(attrs)) elem.setAttribute(k, String(v));
1031
1344
  }
1032
1345
  //#endregion
1033
- //#region src/gantt-chart/vanilla/dom/timeHeader.ts
1346
+ //#region src/lib/vanilla/dom/timeHeader.ts
1034
1347
  function specialDayKind(date, specialDaysByDate, showWeekends, weekendDays) {
1035
1348
  const dateKey = startOfDay(date).toISOString().slice(0, 10);
1036
1349
  const specialDay = specialDaysByDate.get(dateKey);
@@ -1168,7 +1481,7 @@ function renderTimeHeader(container, state) {
1168
1481
  container.append(lowerRow);
1169
1482
  }
1170
1483
  //#endregion
1171
- //#region src/gantt-chart/vanilla/dom/gridColumns.ts
1484
+ //#region src/lib/vanilla/dom/gridColumns.ts
1172
1485
  const DEFAULT_GRID_COLUMNS = [
1173
1486
  {
1174
1487
  id: "name",
@@ -1249,8 +1562,8 @@ function visibleColumns(columns) {
1249
1562
  return columns.filter((c) => c.visible !== false);
1250
1563
  }
1251
1564
  const GRID_COLUMN_FR_MIN_WIDTH = 120;
1252
- const PX_RE = /^(\d+(?:\.\d+)?)px$/;
1253
- const FR_RE = /^(\d+(?:\.\d+)?)fr$/;
1565
+ const PX_RE = /^(\d+(?:\.\d+)?)px$/u;
1566
+ const FR_RE = /^(\d+(?:\.\d+)?)fr$/u;
1254
1567
  function parseColumnMinWidth(width) {
1255
1568
  const trimmed = width.trim();
1256
1569
  const pxMatch = PX_RE.exec(trimmed);
@@ -1272,21 +1585,45 @@ function gridNaturalWidth(columns) {
1272
1585
  return total;
1273
1586
  }
1274
1587
  //#endregion
1275
- //#region src/gantt-chart/vanilla/dom/leftPane.ts
1588
+ //#region src/lib/vanilla/dom/leftPane.ts
1276
1589
  const INDENT = 16;
1277
1590
  const COLUMN_MIN_WIDTH = 30;
1278
- function toTask$1(row) {
1279
- return {
1280
- id: row.id,
1281
- text: row.text,
1282
- startDate: row.startDate,
1283
- durationHours: row.durationHours,
1284
- percentComplete: row.percentComplete,
1285
- type: row.type,
1286
- open: row.open,
1287
- ...row.parent === void 0 ? {} : { parent: row.parent },
1288
- ...row.color === void 0 ? {} : { color: row.color }
1591
+ function toTask$1(node) {
1592
+ const base = {
1593
+ id: node.id,
1594
+ text: node.text,
1595
+ startDate: node.startDate,
1596
+ ...node.parent === void 0 ? {} : { parent: node.parent },
1597
+ ...node.color === void 0 ? {} : { color: node.color },
1598
+ ...node.data === void 0 ? {} : { data: node.data }
1289
1599
  };
1600
+ switch (node.kind) {
1601
+ case "task": return {
1602
+ ...base,
1603
+ kind: "task",
1604
+ durationHours: node.durationHours,
1605
+ percentComplete: node.percentComplete
1606
+ };
1607
+ case "project": return {
1608
+ ...base,
1609
+ kind: "project",
1610
+ durationHours: node.durationHours,
1611
+ percentComplete: node.percentComplete,
1612
+ open: node.open
1613
+ };
1614
+ case "milestone": return {
1615
+ ...base,
1616
+ kind: "milestone"
1617
+ };
1618
+ }
1619
+ }
1620
+ function getTaskField(task, field) {
1621
+ switch (field) {
1622
+ case "durationHours": return task.kind !== "milestone" ? task.durationHours : void 0;
1623
+ case "percentComplete": return task.kind !== "milestone" ? task.percentComplete : void 0;
1624
+ case "open": return task.kind === "project" ? task.open : void 0;
1625
+ default: return task[field];
1626
+ }
1290
1627
  }
1291
1628
  function buildTreeNameCell(row, expandedIds, cbs) {
1292
1629
  const hasChildren = isParent(row);
@@ -1330,7 +1667,7 @@ function buildTreeNameCell(row, expandedIds, cbs) {
1330
1667
  const label = el("span");
1331
1668
  css(label, {
1332
1669
  fontSize: "var(--gantt-font-size-md)",
1333
- fontWeight: row.type === "project" ? "var(--gantt-font-weight-bold)" : "var(--gantt-font-weight-normal)",
1670
+ fontWeight: row.kind === "project" ? "var(--gantt-font-weight-bold)" : "var(--gantt-font-weight-normal)",
1334
1671
  color: "var(--gantt-text)",
1335
1672
  overflow: "hidden",
1336
1673
  textOverflow: "ellipsis",
@@ -1354,9 +1691,9 @@ function buildDataCell(row, column, locale) {
1354
1691
  css(cell, styles);
1355
1692
  const task = toTask$1(row);
1356
1693
  if (column.field !== void 0) {
1357
- const rawValue = task[column.field];
1694
+ const rawValue = getTaskField(task, column.field);
1358
1695
  if (column.format !== void 0) cell.textContent = column.format(rawValue, task, row, locale);
1359
- else cell.textContent = rawValue !== null && rawValue !== void 0 ? String(rawValue) : "";
1696
+ else cell.textContent = rawValue !== null && rawValue !== void 0 ? typeof rawValue === "object" ? JSON.stringify(rawValue) : String(rawValue) : "";
1360
1697
  }
1361
1698
  return cell;
1362
1699
  }
@@ -1375,7 +1712,7 @@ function buildAddButton(row, cbs, locale) {
1375
1712
  });
1376
1713
  btn.addEventListener("click", (event) => {
1377
1714
  event.stopPropagation();
1378
- cbs.onAdd(row.id);
1715
+ cbs.onTaskAdd(row.id);
1379
1716
  });
1380
1717
  return btn;
1381
1718
  }
@@ -1415,7 +1752,7 @@ function buildRow(row, selectedId, expandedIds, cbs, columns, locale) {
1415
1752
  wrapper.addEventListener("keydown", (event) => {
1416
1753
  if (event.key === "Enter" || event.key === " ") {
1417
1754
  event.preventDefault();
1418
- cbs.onSelect(row.id);
1755
+ cbs.onTaskClick(row.id);
1419
1756
  }
1420
1757
  });
1421
1758
  for (const column of visibleColumns(columns)) wrapper.append(buildCell(column, row, expandedIds, cbs, locale));
@@ -1566,23 +1903,22 @@ function setupColumnResize(headerEl, bodyEl, columns, onChange) {
1566
1903
  };
1567
1904
  }
1568
1905
  //#endregion
1569
- //#region src/gantt-chart/vanilla/dom/dependencyLayer.ts
1906
+ //#region src/lib/vanilla/dom/dependencyLayer.ts
1570
1907
  const NS = "http://www.w3.org/2000/svg";
1908
+ const ARROW_PATH = "M 0 1 L 10 5 L 0 9 Z";
1571
1909
  const ARROW_SIZE = 6;
1572
1910
  /**
1573
1911
  * Creates the SVG overlay element. Call once; pass to updateDependencyLayer on each render.
1574
1912
  * Also creates a hidden ghost-line path used during link-creation drags.
1575
1913
  *
1576
- * @param totalWidth - The total pixel width of the SVG viewport.
1577
- * @param totalHeight - The total pixel height of the SVG viewport.
1914
+ * The SVG is initially zero-sized; `updateDependencyLayer` sets width/height each frame.
1915
+ *
1916
+ * @param _totalWidth - The total pixel width of the SVG viewport.
1917
+ * @param _totalHeight - The total pixel height of the SVG viewport.
1578
1918
  * @returns An `SVGSVGElement` ready to be inserted into the DOM.
1579
1919
  */
1580
- function createDependencyLayer(totalWidth, totalHeight) {
1920
+ function createDependencyLayer(_totalWidth, _totalHeight) {
1581
1921
  const svg = document.createElementNS(NS, "svg");
1582
- setAttrs(svg, {
1583
- width: totalWidth,
1584
- height: totalHeight
1585
- });
1586
1922
  Object.assign(svg.style, {
1587
1923
  position: "absolute",
1588
1924
  top: "0",
@@ -1597,7 +1933,7 @@ function createDependencyLayer(totalWidth, totalHeight) {
1597
1933
  setAttrs(marker, {
1598
1934
  id,
1599
1935
  viewBox: "0 0 10 10",
1600
- refX: "9",
1936
+ refX: "10",
1601
1937
  refY: "5",
1602
1938
  markerWidth: ARROW_SIZE,
1603
1939
  markerHeight: ARROW_SIZE,
@@ -1605,7 +1941,7 @@ function createDependencyLayer(totalWidth, totalHeight) {
1605
1941
  });
1606
1942
  const path = document.createElementNS(NS, "path");
1607
1943
  setAttrs(path, {
1608
- d: "M 0 1 L 10 5 L 0 9 Z",
1944
+ d: ARROW_PATH,
1609
1945
  fill: color
1610
1946
  });
1611
1947
  marker.append(path);
@@ -1638,10 +1974,9 @@ function createDependencyLayer(totalWidth, totalHeight) {
1638
1974
  function showGhostLine(svg, x1, y1, x2, y2, valid) {
1639
1975
  const ghost = svg.querySelector("path.gantt-ghost-line");
1640
1976
  if (ghost === null) return;
1641
- setAttrs(ghost, {
1642
- d: `M ${x1},${y1} L ${x2},${y2}`,
1643
- "stroke-dasharray": valid ? "none" : "5 3"
1644
- });
1977
+ setAttrs(ghost, { d: `M ${x1},${y1} L ${x2},${y2}` });
1978
+ if (valid) ghost.removeAttribute("stroke-dasharray");
1979
+ else ghost.setAttribute("stroke-dasharray", "5 3");
1645
1980
  if (valid) ghost.setAttribute("marker-end", "url(#gantt-arrow)");
1646
1981
  else ghost.removeAttribute("marker-end");
1647
1982
  ghost.style.display = "";
@@ -1668,26 +2003,21 @@ function hideGhostLine(svg) {
1668
2003
  * @param totalHeight - The total pixel height of the SVG viewport.
1669
2004
  * @param selectedTaskId - The currently selected task ID, or `null`.
1670
2005
  * @param highlightLinkedDependenciesOnSelect - When `true`, links connected to the selected task use highlight styling.
2006
+ * @param cbs - Optional callbacks for link click and double-click events.
1671
2007
  */
1672
- function updateDependencyLayer(svg, links, totalWidth, totalHeight, selectedTaskId, highlightLinkedDependenciesOnSelect) {
2008
+ function updateDependencyLayer(svg, links, totalWidth, totalHeight, selectedTaskId, highlightLinkedDependenciesOnSelect, cbs) {
1673
2009
  setAttrs(svg, {
1674
2010
  width: totalWidth,
1675
2011
  height: totalHeight
1676
2012
  });
1677
- const toRemove = [];
1678
- for (let i = 1; i < svg.children.length; i++) {
1679
- const child = svg.children[i];
1680
- if (child !== void 0 && !child.classList.contains("gantt-ghost-line")) toRemove.push(child);
1681
- }
2013
+ const toRemove = [...svg.children].slice(1).filter((c) => !c.classList.contains("gantt-ghost-line"));
1682
2014
  for (const node of toRemove) svg.removeChild(node);
2015
+ const ghost = svg.querySelector("path.gantt-ghost-line");
1683
2016
  for (const link of links) {
1684
2017
  const { points } = link;
1685
2018
  if (points.length === 0) continue;
1686
- let d = `M ${points[0]?.x ?? 0},${points[0]?.y ?? 0}`;
1687
- for (let i = 1; i < points.length; i++) {
1688
- const p = points[i];
1689
- if (p !== void 0) d += ` L ${p.x},${p.y}`;
1690
- }
2019
+ const [first, ...rest] = points;
2020
+ const d = `M ${first.x},${first.y}${rest.map((p) => ` L ${p.x},${p.y}`).join("")}`;
1691
2021
  const isRelated = highlightLinkedDependenciesOnSelect && selectedTaskId !== null && (link.sourceTaskId === selectedTaskId || link.targetTaskId === selectedTaskId);
1692
2022
  const path = document.createElementNS(NS, "path");
1693
2023
  setAttrs(path, {
@@ -1696,25 +2026,62 @@ function updateDependencyLayer(svg, links, totalWidth, totalHeight, selectedTask
1696
2026
  stroke: isRelated ? "var(--gantt-link-hi)" : "var(--gantt-link)",
1697
2027
  "stroke-width": isRelated ? "1.8" : "1.5",
1698
2028
  "stroke-linejoin": "round",
1699
- "marker-end": isRelated ? "url(#gantt-arrow-hi)" : "url(#gantt-arrow)"
2029
+ "marker-end": isRelated ? "url(#gantt-arrow-hi)" : "url(#gantt-arrow)",
2030
+ "data-link-id": String(link.linkId)
2031
+ });
2032
+ path.style.pointerEvents = "visibleStroke";
2033
+ path.style.cursor = "pointer";
2034
+ path.addEventListener("click", (event) => {
2035
+ if (event.detail === 1) cbs?.onLinkClick?.({
2036
+ id: link.linkId,
2037
+ source: link.sourceTaskId,
2038
+ target: link.targetTaskId,
2039
+ type: link.type
2040
+ });
1700
2041
  });
1701
- svg.append(path);
2042
+ path.addEventListener("dblclick", (_event) => {
2043
+ cbs?.onLinkDblClick?.({
2044
+ id: link.linkId,
2045
+ source: link.sourceTaskId,
2046
+ target: link.targetTaskId,
2047
+ type: link.type
2048
+ });
2049
+ });
2050
+ if (ghost !== null) svg.insertBefore(path, ghost);
2051
+ else svg.append(path);
1702
2052
  }
1703
2053
  }
1704
2054
  //#endregion
1705
- //#region src/gantt-chart/vanilla/interaction/drag.ts
1706
- function toTask(row) {
1707
- return {
1708
- id: row.id,
1709
- text: row.text,
1710
- startDate: row.startDate,
1711
- durationHours: row.durationHours,
1712
- percentComplete: row.percentComplete,
1713
- type: row.type,
1714
- open: row.open,
1715
- ...row.parent === void 0 ? {} : { parent: row.parent },
1716
- ...row.color === void 0 ? {} : { color: row.color }
2055
+ //#region src/lib/vanilla/interaction/drag.ts
2056
+ function toTask(node) {
2057
+ const base = {
2058
+ id: node.id,
2059
+ text: node.text,
2060
+ startDate: node.startDate,
2061
+ ...node.parent === void 0 ? {} : { parent: node.parent },
2062
+ ...node.color === void 0 ? {} : { color: node.color },
2063
+ ...node.readonly === void 0 ? {} : { readonly: node.readonly },
2064
+ ...node.data === void 0 ? {} : { data: node.data }
1717
2065
  };
2066
+ switch (node.kind) {
2067
+ case "task": return {
2068
+ ...base,
2069
+ kind: "task",
2070
+ durationHours: node.durationHours,
2071
+ percentComplete: node.percentComplete
2072
+ };
2073
+ case "project": return {
2074
+ ...base,
2075
+ kind: "project",
2076
+ durationHours: node.durationHours,
2077
+ percentComplete: node.percentComplete,
2078
+ open: node.open
2079
+ };
2080
+ case "milestone": return {
2081
+ ...base,
2082
+ kind: "milestone"
2083
+ };
2084
+ }
1718
2085
  }
1719
2086
  /**
1720
2087
  * Attaches drag-to-move and resize listeners to a bar element.
@@ -1736,22 +2103,27 @@ function attachDrag(barEl, resizeHandleEl, task, getMapper, cbs) {
1736
2103
  try {
1737
2104
  barEl.setPointerCapture(e.pointerId);
1738
2105
  } catch {}
1739
- cbs.onSelect?.(task.id);
2106
+ cbs.onTaskClick?.(task.id);
1740
2107
  const startX = e.clientX;
1741
2108
  const originDate = parseDate(task.startDate);
1742
2109
  const mapper = getMapper();
2110
+ let lastHours = 0;
1743
2111
  function onMove(me) {
1744
2112
  const dx = me.clientX - startX;
1745
- const hours = Math.round(mapper.widthToDuration(dx));
1746
- cbs.onMove?.({
2113
+ lastHours = Math.round(mapper.widthToDuration(dx));
2114
+ cbs.onTaskMove?.({
1747
2115
  id: task.id,
1748
- startDate: addHours(originDate, hours)
2116
+ startDate: addHours(originDate, lastHours)
1749
2117
  });
1750
2118
  }
1751
2119
  function onUp() {
1752
2120
  window.removeEventListener("pointermove", onMove);
1753
2121
  window.removeEventListener("pointerup", onUp);
1754
2122
  barEl.style.cursor = "grab";
2123
+ if (lastHours !== 0) cbs._onTaskMoveFinal?.({
2124
+ id: task.id,
2125
+ startDate: addHours(originDate, lastHours)
2126
+ });
1755
2127
  }
1756
2128
  barEl.style.cursor = "grabbing";
1757
2129
  window.addEventListener("pointermove", onMove);
@@ -1765,29 +2137,33 @@ function attachDrag(barEl, resizeHandleEl, task, getMapper, cbs) {
1765
2137
  resizeHandleEl.setPointerCapture(e.pointerId);
1766
2138
  } catch {}
1767
2139
  const startX = e.clientX;
1768
- const origDur = task.durationHours;
2140
+ const origDur = task.kind !== "milestone" ? task.durationHours : 0;
1769
2141
  const mapper = getMapper();
2142
+ let lastDuration = origDur;
1770
2143
  function onMove(me) {
1771
2144
  const dx = me.clientX - startX;
1772
2145
  const hoursDelta = Math.round(mapper.widthToDuration(dx));
1773
- cbs.onResize?.({
2146
+ lastDuration = Math.max(1, origDur + hoursDelta);
2147
+ cbs.onTaskResize?.({
1774
2148
  id: task.id,
1775
- durationHours: Math.max(1, origDur + hoursDelta)
2149
+ durationHours: lastDuration
1776
2150
  });
1777
2151
  }
1778
2152
  function onUp() {
1779
2153
  window.removeEventListener("pointermove", onMove);
1780
2154
  window.removeEventListener("pointerup", onUp);
2155
+ cbs._onTaskResizeFinal?.({
2156
+ id: task.id,
2157
+ durationHours: lastDuration
2158
+ });
1781
2159
  }
1782
2160
  window.addEventListener("pointermove", onMove);
1783
2161
  window.addEventListener("pointerup", onUp);
1784
2162
  }
1785
2163
  function onBarClick(event) {
1786
2164
  if (event.detail !== 2) return;
1787
- cbs.onTaskEditIntent?.({
2165
+ cbs.onTaskDoubleClick?.({
1788
2166
  id: task.id,
1789
- source: "bar",
1790
- trigger: "doubleClick",
1791
2167
  task: toTask(task)
1792
2168
  });
1793
2169
  }
@@ -1801,6 +2177,56 @@ function attachDrag(barEl, resizeHandleEl, task, getMapper, cbs) {
1801
2177
  };
1802
2178
  }
1803
2179
  /**
2180
+ * Attaches drag-to-change-progress listeners to a progress overlay element.
2181
+ *
2182
+ * @param progressEl - The progress overlay DOM element.
2183
+ * @param barEl - The bar DOM element (for width measurement).
2184
+ * @param task - The {@link TaskNode} for this bar.
2185
+ * @param _getMapper - A function returning the current {@link PixelMapper} (unused, kept for API symmetry).
2186
+ * @param cbs - The chart callbacks.
2187
+ * @returns A cleanup function that removes all listeners.
2188
+ */
2189
+ function attachProgressDrag(progressEl, barEl, task, _getMapper, cbs) {
2190
+ function onProgressDown(e) {
2191
+ if (e.button !== 0) return;
2192
+ e.preventDefault();
2193
+ e.stopPropagation();
2194
+ cbs.onTaskClick?.(task.id);
2195
+ try {
2196
+ progressEl.setPointerCapture(e.pointerId);
2197
+ } catch {}
2198
+ const startX = e.clientX;
2199
+ const barWidth = barEl.getBoundingClientRect().width;
2200
+ const origPercent = task.kind !== "milestone" ? task.percentComplete ?? 0 : 0;
2201
+ let lastPercent = origPercent;
2202
+ function onMove(me) {
2203
+ const dx = me.clientX - startX;
2204
+ const percentDelta = barWidth > 0 ? dx / barWidth * 100 : 0;
2205
+ lastPercent = Math.max(0, Math.min(100, Math.round(origPercent + percentDelta)));
2206
+ cbs.onTaskProgressDrag?.({
2207
+ id: task.id,
2208
+ percentComplete: lastPercent
2209
+ });
2210
+ }
2211
+ function onUp() {
2212
+ window.removeEventListener("pointermove", onMove);
2213
+ window.removeEventListener("pointerup", onUp);
2214
+ progressEl.style.cursor = "ew-resize";
2215
+ cbs._onTaskProgressDragFinal?.({
2216
+ id: task.id,
2217
+ percentComplete: lastPercent
2218
+ });
2219
+ }
2220
+ progressEl.style.cursor = "ew-resize";
2221
+ window.addEventListener("pointermove", onMove);
2222
+ window.addEventListener("pointerup", onUp);
2223
+ }
2224
+ progressEl.addEventListener("pointerdown", onProgressDown);
2225
+ return () => {
2226
+ progressEl.removeEventListener("pointerdown", onProgressDown);
2227
+ };
2228
+ }
2229
+ /**
1804
2230
  * Attaches click-to-select on a milestone diamond.
1805
2231
  *
1806
2232
  * @param diamondEl - The milestone diamond DOM element.
@@ -1810,16 +2236,14 @@ function attachDrag(barEl, resizeHandleEl, task, getMapper, cbs) {
1810
2236
  */
1811
2237
  function attachMilestoneClick(diamondEl, taskId, cbs) {
1812
2238
  function onClick() {
1813
- cbs.onSelect?.(taskId);
2239
+ cbs.onTaskClick?.(taskId);
1814
2240
  }
1815
2241
  function onDoubleClick(event) {
1816
2242
  if (event.detail === 2) {
1817
2243
  const task = diamondEl.__task;
1818
2244
  if (task === void 0) return;
1819
- cbs.onTaskEditIntent?.({
2245
+ cbs.onTaskDoubleClick?.({
1820
2246
  id: taskId,
1821
- source: "milestone",
1822
- trigger: "doubleClick",
1823
2247
  task
1824
2248
  });
1825
2249
  }
@@ -1835,7 +2259,7 @@ function bindMilestoneTask(diamondEl, task) {
1835
2259
  diamondEl.__task = task;
1836
2260
  }
1837
2261
  //#endregion
1838
- //#region src/gantt-chart/vanilla/interaction/linkCreation.ts
2262
+ //#region src/lib/vanilla/interaction/linkCreation.ts
1839
2263
  /**
1840
2264
  * Attaches a link-creation drag listener to an endpoint handle.
1841
2265
  *
@@ -1917,7 +2341,7 @@ function createEndpointHandle() {
1917
2341
  return handle;
1918
2342
  }
1919
2343
  //#endregion
1920
- //#region src/gantt-chart/vanilla/dom/rightPane.ts
2344
+ //#region src/lib/vanilla/dom/rightPane.ts
1921
2345
  const BAR_COLOR = {
1922
2346
  task: "var(--gantt-task)",
1923
2347
  project: "var(--gantt-project)",
@@ -1942,11 +2366,16 @@ function createRightPaneRefs() {
1942
2366
  scrollContainer.append(stripeContainer);
1943
2367
  scrollContainer.append(absoluteLayer);
1944
2368
  absoluteLayer.append(svgLayer);
2369
+ const tooltipEl = el("div");
2370
+ tooltipEl.className = "gantt-tooltip";
2371
+ tooltipEl.style.display = "none";
2372
+ scrollContainer.append(tooltipEl);
1945
2373
  return {
1946
2374
  scrollContainer,
1947
2375
  stripeContainer,
1948
2376
  absoluteLayer,
1949
2377
  svgLayer,
2378
+ tooltipEl,
1950
2379
  barRegistry: /* @__PURE__ */ new Map()
1951
2380
  };
1952
2381
  }
@@ -1991,9 +2420,10 @@ function renderSpecialDayBackgrounds(layer, beforeNode, state, contentHeight) {
1991
2420
  cur = next;
1992
2421
  }
1993
2422
  }
1994
- function renderBar(layer, svgLayer, task, layout, selectedId, registry, state, cbs) {
2423
+ function renderBar(layer, svgLayer, task, layout, selectedId, registry, state, cbs, tooltipEl) {
1995
2424
  const selected = task.id === selectedId;
1996
- const color = BAR_COLOR[layout.type] ?? BAR_COLOR["task"];
2425
+ const readonly = task.readonly === true;
2426
+ const color = BAR_COLOR[layout.kind] ?? BAR_COLOR["task"];
1997
2427
  const bar = el("div");
1998
2428
  bar.className = `gantt-bar${selected ? " gantt-bar--selected gantt-shape--selected" : ""}`;
1999
2429
  css(bar, {
@@ -2003,15 +2433,17 @@ function renderBar(layer, svgLayer, task, layout, selectedId, registry, state, c
2003
2433
  width: `${layout.width}px`,
2004
2434
  height: `${layout.height}px`,
2005
2435
  ...color === void 0 ? {} : { background: color },
2006
- borderRadius: layout.type === "project" ? "3px" : "4px",
2007
- cursor: "grab",
2436
+ borderRadius: layout.kind === "project" ? "3px" : "4px",
2437
+ cursor: readonly ? "pointer" : "grab",
2008
2438
  userSelect: "none",
2009
2439
  overflow: "hidden",
2010
2440
  zIndex: selected ? "3" : "2",
2011
2441
  touchAction: "none"
2012
2442
  });
2443
+ let cleanupProgressDrag;
2013
2444
  if (layout.progressWidth > 0) {
2014
2445
  const prog = el("div");
2446
+ const progressEnabled = state.progressDragEnabled;
2015
2447
  css(prog, {
2016
2448
  position: "absolute",
2017
2449
  left: "0",
@@ -2019,8 +2451,15 @@ function renderBar(layer, svgLayer, task, layout, selectedId, registry, state, c
2019
2451
  width: `${layout.progressWidth}px`,
2020
2452
  height: "100%",
2021
2453
  background: "rgba(0,0,0,0.18)",
2022
- pointerEvents: "none"
2454
+ ...progressEnabled ? {
2455
+ cursor: "ew-resize",
2456
+ touchAction: "none"
2457
+ } : { pointerEvents: "none" }
2023
2458
  });
2459
+ if (progressEnabled) {
2460
+ prog.className = "gantt-progress-overlay";
2461
+ cleanupProgressDrag = attachProgressDrag(prog, bar, task, () => state.mapper, cbs);
2462
+ }
2024
2463
  bar.append(prog);
2025
2464
  }
2026
2465
  const label = el("span");
@@ -2046,30 +2485,38 @@ function renderBar(layer, svgLayer, task, layout, selectedId, registry, state, c
2046
2485
  bar.setAttribute("aria-label", ariaLabel(state.locale, "ariaTask", task.text));
2047
2486
  bar.setAttribute("aria-pressed", String(selected));
2048
2487
  bar.dataset["taskId"] = String(task.id);
2049
- bar.addEventListener("click", () => {
2050
- cbs.onSelect?.(task.id);
2488
+ bar.addEventListener("click", (event) => {
2489
+ if (event.detail === 2) cbs.onTaskDoubleClick?.({
2490
+ id: task.id,
2491
+ task: toTask(task)
2492
+ });
2493
+ else cbs.onTaskClick?.(task.id);
2051
2494
  });
2052
2495
  bar.addEventListener("keydown", (event) => {
2053
2496
  if (event.key === "Enter" || event.key === " ") {
2054
2497
  event.preventDefault();
2055
- cbs.onSelect?.(task.id);
2498
+ cbs.onTaskClick?.(task.id);
2056
2499
  }
2057
2500
  });
2058
- const handle = el("div");
2059
- handle.className = "gantt-resize-handle";
2060
- css(handle, {
2061
- position: "absolute",
2062
- right: "0",
2063
- top: "0",
2064
- width: "8px",
2065
- height: "100%",
2066
- cursor: "ew-resize",
2067
- zIndex: "1",
2068
- touchAction: "none"
2069
- });
2070
- bar.append(handle);
2071
- layer.insertBefore(bar, svgLayer);
2072
- const cleanupDrag = attachDrag(bar, handle, task, () => state.mapper, cbs);
2501
+ let handle;
2502
+ let cleanupDrag;
2503
+ if (!readonly) {
2504
+ handle = el("div");
2505
+ handle.className = "gantt-resize-handle";
2506
+ css(handle, {
2507
+ position: "absolute",
2508
+ right: "0",
2509
+ top: "0",
2510
+ width: "8px",
2511
+ height: "100%",
2512
+ cursor: "ew-resize",
2513
+ zIndex: "1",
2514
+ touchAction: "none"
2515
+ });
2516
+ bar.append(handle);
2517
+ layer.insertBefore(bar, svgLayer);
2518
+ cleanupDrag = attachDrag(bar, handle, task, () => state.mapper, cbs);
2519
+ } else layer.insertBefore(bar, svgLayer);
2073
2520
  let cleanupLinkHandles;
2074
2521
  if (state.linkCreationEnabled) {
2075
2522
  const barCenterY = layout.y + layout.height / 2;
@@ -2104,16 +2551,52 @@ function renderBar(layer, svgLayer, task, layout, selectedId, registry, state, c
2104
2551
  bar.removeEventListener("mouseleave", onBarLeave);
2105
2552
  };
2106
2553
  }
2554
+ const onTooltipEnter = () => {
2555
+ const content = cbs.onTooltipText?.({
2556
+ id: task.id,
2557
+ task: toTask(task)
2558
+ });
2559
+ if (content && content.length > 0) {
2560
+ tooltipEl.innerHTML = content;
2561
+ tooltipEl.style.display = "";
2562
+ } else tooltipEl.style.display = "none";
2563
+ };
2564
+ const onTooltipMove = (e) => {
2565
+ const offsetX = 12;
2566
+ const offsetY = -8;
2567
+ let left = e.clientX + offsetX;
2568
+ let top = e.clientY + offsetY;
2569
+ const maxLeft = window.innerWidth - tooltipEl.offsetWidth - 4;
2570
+ const maxTop = window.innerHeight - tooltipEl.offsetHeight - 4;
2571
+ left = Math.max(4, Math.min(left, maxLeft));
2572
+ top = Math.max(4, Math.min(top, maxTop));
2573
+ tooltipEl.style.left = `${left}px`;
2574
+ tooltipEl.style.top = `${top}px`;
2575
+ };
2576
+ const onTooltipLeave = () => {
2577
+ tooltipEl.style.display = "none";
2578
+ };
2579
+ bar.addEventListener("mouseenter", onTooltipEnter);
2580
+ bar.addEventListener("mousemove", onTooltipMove);
2581
+ bar.addEventListener("mouseleave", onTooltipLeave);
2582
+ const cleanupTooltip = () => {
2583
+ bar.removeEventListener("mouseenter", onTooltipEnter);
2584
+ bar.removeEventListener("mousemove", onTooltipMove);
2585
+ bar.removeEventListener("mouseleave", onTooltipLeave);
2586
+ };
2107
2587
  const entry = {
2108
2588
  bar,
2109
- resizeHandle: handle,
2110
- cleanupDrag
2589
+ resizeHandle: handle ?? el("div")
2111
2590
  };
2591
+ if (cleanupDrag !== void 0) entry.cleanupDrag = cleanupDrag;
2112
2592
  if (cleanupLinkHandles !== void 0) entry.cleanupLinkHandles = cleanupLinkHandles;
2593
+ if (cleanupProgressDrag !== void 0) entry.cleanupProgressDrag = cleanupProgressDrag;
2594
+ if (cleanupTooltip !== void 0) entry.cleanupTooltip = cleanupTooltip;
2113
2595
  registry.set(task.id, entry);
2114
2596
  }
2115
- function renderMilestone(layer, svgLayer, task, layout, selectedId, registry, cbs, state) {
2597
+ function renderMilestone(layer, svgLayer, task, layout, selectedId, registry, cbs, state, tooltipEl) {
2116
2598
  const selected = task.id === selectedId;
2599
+ const readonly = task.readonly === true;
2117
2600
  const size = MILESTONE_HALF * 2;
2118
2601
  const diamond = el("div");
2119
2602
  diamond.className = `gantt-milestone${selected ? " gantt-shape--selected" : ""}`;
@@ -2125,7 +2608,7 @@ function renderMilestone(layer, svgLayer, task, layout, selectedId, registry, cb
2125
2608
  height: `${size}px`,
2126
2609
  background: "var(--gantt-milestone)",
2127
2610
  transform: "rotate(45deg)",
2128
- cursor: "pointer",
2611
+ cursor: readonly ? "default" : "pointer",
2129
2612
  zIndex: "4"
2130
2613
  });
2131
2614
  diamond.tabIndex = 0;
@@ -2136,7 +2619,7 @@ function renderMilestone(layer, svgLayer, task, layout, selectedId, registry, cb
2136
2619
  diamond.addEventListener("keydown", (event) => {
2137
2620
  if (event.key === "Enter" || event.key === " ") {
2138
2621
  event.preventDefault();
2139
- cbs.onSelect?.(task.id);
2622
+ cbs.onTaskClick?.(task.id);
2140
2623
  }
2141
2624
  });
2142
2625
  const labelEl = el("span");
@@ -2156,7 +2639,15 @@ function renderMilestone(layer, svgLayer, task, layout, selectedId, registry, cb
2156
2639
  layer.insertBefore(diamond, svgLayer);
2157
2640
  bindMilestoneTask(diamond, task);
2158
2641
  const dummy = el("div");
2159
- const cleanupDrag = attachMilestoneClick(diamond, task.id, cbs);
2642
+ let cleanupDrag;
2643
+ if (!readonly) cleanupDrag = attachMilestoneClick(diamond, task.id, cbs);
2644
+ else diamond.addEventListener("click", (event) => {
2645
+ if (event.detail === 2) cbs.onTaskDoubleClick?.({
2646
+ id: task.id,
2647
+ task: toTask(task)
2648
+ });
2649
+ else cbs.onTaskClick?.(task.id);
2650
+ });
2160
2651
  let cleanupLinkHandles;
2161
2652
  if (state.linkCreationEnabled) {
2162
2653
  const diamondCenterY = layout.y + layout.height / 2;
@@ -2182,12 +2673,46 @@ function renderMilestone(layer, svgLayer, task, layout, selectedId, registry, cb
2182
2673
  diamond.removeEventListener("mouseleave", onDiamondLeave);
2183
2674
  };
2184
2675
  }
2676
+ const onTooltipEnter = () => {
2677
+ const content = cbs.onTooltipText?.({
2678
+ id: task.id,
2679
+ task: toTask(task)
2680
+ });
2681
+ if (content && content.length > 0) {
2682
+ tooltipEl.innerHTML = content;
2683
+ tooltipEl.style.display = "";
2684
+ } else tooltipEl.style.display = "none";
2685
+ };
2686
+ const onTooltipMove = (e) => {
2687
+ const offsetX = 12;
2688
+ const offsetY = -8;
2689
+ let left = e.clientX + offsetX;
2690
+ let top = e.clientY + offsetY;
2691
+ const maxLeft = window.innerWidth - tooltipEl.offsetWidth - 4;
2692
+ const maxTop = window.innerHeight - tooltipEl.offsetHeight - 4;
2693
+ left = Math.max(4, Math.min(left, maxLeft));
2694
+ top = Math.max(4, Math.min(top, maxTop));
2695
+ tooltipEl.style.left = `${left}px`;
2696
+ tooltipEl.style.top = `${top}px`;
2697
+ };
2698
+ const onTooltipLeave = () => {
2699
+ tooltipEl.style.display = "none";
2700
+ };
2701
+ diamond.addEventListener("mouseenter", onTooltipEnter);
2702
+ diamond.addEventListener("mousemove", onTooltipMove);
2703
+ diamond.addEventListener("mouseleave", onTooltipLeave);
2704
+ const cleanupTooltip = () => {
2705
+ diamond.removeEventListener("mouseenter", onTooltipEnter);
2706
+ diamond.removeEventListener("mousemove", onTooltipMove);
2707
+ diamond.removeEventListener("mouseleave", onTooltipLeave);
2708
+ };
2185
2709
  const entry = {
2186
2710
  bar: diamond,
2187
- resizeHandle: dummy,
2188
- cleanupDrag
2711
+ resizeHandle: dummy
2189
2712
  };
2713
+ if (cleanupDrag !== void 0) entry.cleanupDrag = cleanupDrag;
2190
2714
  if (cleanupLinkHandles !== void 0) entry.cleanupLinkHandles = cleanupLinkHandles;
2715
+ if (cleanupTooltip !== void 0) entry.cleanupTooltip = cleanupTooltip;
2191
2716
  registry.set(task.id, entry);
2192
2717
  }
2193
2718
  /**
@@ -2235,9 +2760,10 @@ function renderRightPane(refs, state, cbs) {
2235
2760
  for (const child of [...absoluteLayer.children]) if (child !== svgLayer) toRemove.push(child);
2236
2761
  for (const node of toRemove) absoluteLayer.removeChild(node);
2237
2762
  hideGhostLine(svgLayer);
2238
- for (const { cleanupDrag, cleanupLinkHandles } of barRegistry.values()) {
2239
- cleanupDrag();
2763
+ for (const { cleanupDrag, cleanupLinkHandles, cleanupProgressDrag } of barRegistry.values()) {
2764
+ cleanupDrag?.();
2240
2765
  cleanupLinkHandles?.();
2766
+ cleanupProgressDrag?.();
2241
2767
  }
2242
2768
  barRegistry.clear();
2243
2769
  if (scale === "day") renderSpecialDayBackgrounds(absoluteLayer, svgLayer, state, contentHeight);
@@ -2278,13 +2804,13 @@ function renderRightPane(refs, state, cbs) {
2278
2804
  for (const task of visibleRows) {
2279
2805
  const layout = layouts.get(task.id);
2280
2806
  if (layout === void 0) continue;
2281
- if (layout.type === "milestone") renderMilestone(absoluteLayer, svgLayer, task, layout, selectedId, barRegistry, cbs, state);
2282
- else renderBar(absoluteLayer, svgLayer, task, layout, selectedId, barRegistry, state, cbs);
2807
+ if (layout.kind === "milestone") renderMilestone(absoluteLayer, svgLayer, task, layout, selectedId, barRegistry, cbs, state, refs.tooltipEl);
2808
+ else renderBar(absoluteLayer, svgLayer, task, layout, selectedId, barRegistry, state, cbs, refs.tooltipEl);
2283
2809
  }
2284
- updateDependencyLayer(svgLayer, links.filter((link) => visibleTaskIds.has(link.sourceTaskId) && visibleTaskIds.has(link.targetTaskId)), totalWidth, contentHeight, selectedId, highlightLinkedDependenciesOnSelect);
2810
+ updateDependencyLayer(svgLayer, links.filter((link) => visibleTaskIds.has(link.sourceTaskId) && visibleTaskIds.has(link.targetTaskId)), totalWidth, contentHeight, selectedId, highlightLinkedDependenciesOnSelect, cbs);
2285
2811
  }
2286
2812
  //#endregion
2287
- //#region src/gantt-chart/vanilla/utils.ts
2813
+ //#region src/lib/vanilla/utils.ts
2288
2814
  function buildTaskIndex(tasks) {
2289
2815
  const index = /* @__PURE__ */ new Map();
2290
2816
  for (let i = 0; i < tasks.length; i++) {
@@ -2333,11 +2859,11 @@ function getExpandableTaskIds(tasks) {
2333
2859
  function getInitialExpandedIds(tasks) {
2334
2860
  const expandableIds = getExpandableTaskIds(tasks);
2335
2861
  const expandedIds = /* @__PURE__ */ new Set();
2336
- for (const task of tasks) if (task.open && expandableIds.has(task.id)) expandedIds.add(task.id);
2862
+ for (const task of tasks) if (task.kind === "project" && task.open && expandableIds.has(task.id)) expandedIds.add(task.id);
2337
2863
  return expandedIds;
2338
2864
  }
2339
2865
  //#endregion
2340
- //#region src/gantt-chart/vanilla/splitter.ts
2866
+ //#region src/lib/vanilla/splitter.ts
2341
2867
  const MIN_PANE_WIDTH$1 = 96;
2342
2868
  function attachSplitter(splitterHandle, leftPane, container, timelineMinWidth, onDragEnd) {
2343
2869
  splitterHandle.addEventListener("pointerdown", (e) => {
@@ -2386,7 +2912,7 @@ function computeLeftPaneWidth(options) {
2386
2912
  return Math.max(MIN_PANE_WIDTH, Math.floor(width));
2387
2913
  }
2388
2914
  //#endregion
2389
- //#region src/gantt-chart/vanilla/gantt-chart.ts
2915
+ //#region src/lib/vanilla/gantt-chart.ts
2390
2916
  const HEADER_H = 52;
2391
2917
  const OVERSCAN = 4;
2392
2918
  /**
@@ -2405,6 +2931,7 @@ const OVERSCAN = 4;
2405
2931
  var GanttChart = class {
2406
2932
  #container;
2407
2933
  #opts;
2934
+ #callbacks;
2408
2935
  #input = null;
2409
2936
  #scale;
2410
2937
  #selectedId = null;
@@ -2412,6 +2939,7 @@ var GanttChart = class {
2412
2939
  #rafPending = false;
2413
2940
  #rafId = null;
2414
2941
  #destroyed = false;
2942
+ #dragOriginals = /* @__PURE__ */ new Map();
2415
2943
  #taskIndex;
2416
2944
  #lastGridClick = null;
2417
2945
  #userSplitWidth = null;
@@ -2426,6 +2954,7 @@ var GanttChart = class {
2426
2954
  #root;
2427
2955
  #scrollEl;
2428
2956
  #leftPane;
2957
+ #leftHeader;
2429
2958
  #leftBody;
2430
2959
  #rightPane;
2431
2960
  #rightHeader;
@@ -2436,14 +2965,16 @@ var GanttChart = class {
2436
2965
  /**
2437
2966
  * Constructs a new chart, builds the DOM, and wires internal event handling.
2438
2967
  * Data must be loaded via {@link update} before the chart renders.
2968
+ * Callbacks must be set via {@link setCallbacks} before user interactions are handled.
2439
2969
  *
2440
2970
  * @param container - The host `HTMLElement` the chart will be appended to.
2441
- * @param opts - Configuration and callback options.
2971
+ * @param opts - Configuration options.
2442
2972
  */
2443
2973
  constructor(container, opts = {}) {
2444
2974
  this.#container = container;
2445
2975
  this.#scale = opts.scale ?? "day";
2446
2976
  this.#opts = opts;
2977
+ this.#callbacks = {};
2447
2978
  this.#taskIndex = /* @__PURE__ */ new Map();
2448
2979
  this.#locale = resolveChartLocale(opts.locale);
2449
2980
  this.#columns = opts.gridColumns ?? gridColumnDefaults(this.#locale);
@@ -2453,50 +2984,189 @@ var GanttChart = class {
2453
2984
  this.#weekendDays = normalizeWeekendDays(opts.weekendDays ?? this.#locale.weekendDays);
2454
2985
  this.#specialDaysByDate = buildSpecialDayIndex(opts.specialDays ?? []);
2455
2986
  this.#expandedIds = /* @__PURE__ */ new Set();
2456
- this.#cbs = {
2457
- onSelect: (id) => {
2987
+ this.#cbs = this.#buildCallbackAdapter();
2988
+ this.#buildDom();
2989
+ this.#wireEvents();
2990
+ container.append(this.#root);
2991
+ this.#applyTheme();
2992
+ this.#applyResponsivePaneStyles();
2993
+ this.#setupResizeObserver();
2994
+ }
2995
+ #buildCallbackAdapter() {
2996
+ return {
2997
+ onTaskClick: (id) => {
2458
2998
  if (this.#selectedId === id) return;
2459
2999
  this.#selectedId = id;
2460
- opts.onSelect?.(this.#selectedId);
3000
+ if (this.#selectedId !== null) {
3001
+ const task = this.#findTask(this.#selectedId);
3002
+ if (task !== void 0) this.#callbacks.onTaskClick?.({
3003
+ task,
3004
+ instance: this
3005
+ });
3006
+ }
2461
3007
  this.#scheduleRender();
2462
3008
  },
2463
3009
  onTaskDoubleClick: (payload) => {
2464
- opts.onTaskDoubleClick?.(payload);
3010
+ this.#callbacks.onTaskDoubleClick?.({
3011
+ task: payload.task,
3012
+ instance: this
3013
+ });
2465
3014
  },
2466
3015
  onTaskEditIntent: (payload) => {
2467
- opts.onTaskEditIntent?.(payload);
2468
- opts.onTaskDoubleClick?.({
2469
- id: payload.id,
2470
- source: payload.source
3016
+ this.#callbacks.onTaskDoubleClick?.({
3017
+ task: payload.task,
3018
+ instance: this
2471
3019
  });
2472
3020
  },
2473
- onMove: (payload) => {
3021
+ onTaskMove: (payload) => {
3022
+ if (!this.#dragOriginals.has(payload.id)) {
3023
+ const task = this.#input?.tasks.find((t) => t.id === payload.id);
3024
+ if (task !== void 0) this.#dragOriginals.set(payload.id, task);
3025
+ }
2474
3026
  const iso = payload.startDate.toISOString().slice(0, 10);
2475
3027
  this.#patchTask(payload.id, { startDate: iso });
2476
- opts.onMove?.(payload);
2477
3028
  this.#scheduleRender();
2478
3029
  },
2479
- onResize: (payload) => {
3030
+ _onTaskMoveFinal: async (payload) => {
3031
+ const task = this.#findTask(payload.id);
3032
+ if (task !== void 0) {
3033
+ const result = this.#callbacks.onTaskMove?.({
3034
+ task,
3035
+ newStartDate: payload.startDate,
3036
+ instance: this
3037
+ });
3038
+ if (result instanceof Promise) {
3039
+ if (!await result) {
3040
+ const original = this.#dragOriginals.get(payload.id);
3041
+ if (original !== void 0) this.#patchTask(payload.id, { startDate: original.startDate });
3042
+ }
3043
+ } else if (!result) {
3044
+ const original = this.#dragOriginals.get(payload.id);
3045
+ if (original !== void 0) this.#patchTask(payload.id, { startDate: original.startDate });
3046
+ }
3047
+ }
3048
+ this.#dragOriginals.clear();
3049
+ this.#scheduleRender();
3050
+ return true;
3051
+ },
3052
+ onTaskResize: (payload) => {
3053
+ if (!this.#dragOriginals.has(payload.id)) {
3054
+ const task = this.#input?.tasks.find((t) => t.id === payload.id);
3055
+ if (task !== void 0) this.#dragOriginals.set(payload.id, task);
3056
+ }
2480
3057
  this.#patchTask(payload.id, { durationHours: payload.durationHours });
2481
- opts.onResize?.(payload);
2482
3058
  this.#scheduleRender();
2483
3059
  },
3060
+ _onTaskResizeFinal: async (payload) => {
3061
+ const task = this.#findTask(payload.id);
3062
+ if (task !== void 0) {
3063
+ const result = this.#callbacks.onTaskResize?.({
3064
+ task,
3065
+ newDurationHours: payload.durationHours,
3066
+ instance: this
3067
+ });
3068
+ if (result instanceof Promise) {
3069
+ if (!await result) {
3070
+ const original = this.#dragOriginals.get(payload.id);
3071
+ if (original !== void 0 && original.kind !== "milestone") this.#patchTask(payload.id, { durationHours: original.durationHours });
3072
+ }
3073
+ } else if (!result) {
3074
+ const original = this.#dragOriginals.get(payload.id);
3075
+ if (original !== void 0 && original.kind !== "milestone") this.#patchTask(payload.id, { durationHours: original.durationHours });
3076
+ }
3077
+ }
3078
+ this.#dragOriginals.clear();
3079
+ this.#scheduleRender();
3080
+ return true;
3081
+ },
3082
+ onTaskProgressDrag: (payload) => {
3083
+ if (!this.#dragOriginals.has(payload.id)) {
3084
+ const task = this.#input?.tasks.find((t) => t.id === payload.id);
3085
+ if (task !== void 0) this.#dragOriginals.set(payload.id, task);
3086
+ }
3087
+ this.#patchTask(payload.id, { percentComplete: payload.percentComplete });
3088
+ this.#scheduleRender();
3089
+ },
3090
+ _onTaskProgressDragFinal: async (payload) => {
3091
+ const task = this.#findTask(payload.id);
3092
+ if (task !== void 0) {
3093
+ const result = this.#callbacks.onProgressChange?.({
3094
+ task,
3095
+ newPercentComplete: payload.percentComplete,
3096
+ instance: this
3097
+ });
3098
+ if (result instanceof Promise) {
3099
+ if (!await result) {
3100
+ const original = this.#dragOriginals.get(payload.id);
3101
+ if (original !== void 0 && original.kind !== "milestone") this.#patchTask(payload.id, { percentComplete: original.percentComplete });
3102
+ }
3103
+ } else if (!result) {
3104
+ const original = this.#dragOriginals.get(payload.id);
3105
+ if (original !== void 0 && original.kind !== "milestone") this.#patchTask(payload.id, { percentComplete: original.percentComplete });
3106
+ }
3107
+ }
3108
+ this.#dragOriginals.clear();
3109
+ this.#scheduleRender();
3110
+ return true;
3111
+ },
3112
+ onTaskAdd: (parentId) => {
3113
+ const parentTask = this.#findTask(parentId);
3114
+ if (parentTask !== void 0) this.#callbacks.onTaskAdd?.({
3115
+ parentTask,
3116
+ instance: this
3117
+ });
3118
+ },
2484
3119
  onLeftPaneWidthChange: (width) => {
2485
- opts.onLeftPaneWidthChange?.(width);
3120
+ this.#callbacks.onLeftPaneWidthChange?.({
3121
+ width,
3122
+ instance: this
3123
+ });
2486
3124
  },
2487
3125
  onGridColumnsChange: (updatedColumns) => {
2488
- opts.onGridColumnsChange?.(updatedColumns);
3126
+ this.#callbacks.onGridColumnsChange?.({
3127
+ columns: updatedColumns,
3128
+ instance: this
3129
+ });
2489
3130
  },
2490
3131
  onLinkCreate: (payload) => {
2491
- opts.onLinkCreate?.(payload);
2492
- }
3132
+ const sourceTask = this.#findTask(payload.sourceTaskId);
3133
+ const targetTask = this.#findTask(payload.targetTaskId);
3134
+ if (sourceTask !== void 0 && targetTask !== void 0) this.#callbacks.onLinkCreate?.({
3135
+ type: "FS",
3136
+ sourceTask,
3137
+ targetTask,
3138
+ instance: this
3139
+ });
3140
+ },
3141
+ onLinkClick: (payload) => {
3142
+ this.#callbacks.onLinkClick?.({
3143
+ link: payload,
3144
+ instance: this
3145
+ });
3146
+ },
3147
+ onLinkDblClick: (payload) => {
3148
+ this.#callbacks.onLinkDblClick?.({
3149
+ link: payload,
3150
+ instance: this
3151
+ });
3152
+ },
3153
+ onTooltipText: (payload) => this.#callbacks.onTooltipText?.({
3154
+ task: payload.task,
3155
+ instance: this
3156
+ }) ?? null
2493
3157
  };
2494
- this.#buildDom();
2495
- this.#wireEvents();
2496
- container.append(this.#root);
2497
- this.#applyTheme();
2498
- this.#applyResponsivePaneStyles();
2499
- this.#setupResizeObserver();
3158
+ }
3159
+ /**
3160
+ * Sets or replaces the chart's user-facing callbacks.
3161
+ * Does not trigger a re-render.
3162
+ *
3163
+ * @param cbs - The {@link GanttCallbacks} to register.
3164
+ * @throws {GanttError} When the instance has been destroyed.
3165
+ */
3166
+ setCallbacks(cbs) {
3167
+ this.#assertAlive();
3168
+ this.#callbacks = cbs;
3169
+ this.#cbs = this.#buildCallbackAdapter();
2500
3170
  }
2501
3171
  /**
2502
3172
  * Replaces the full dataset and re-renders.
@@ -2508,9 +3178,9 @@ var GanttChart = class {
2508
3178
  this.#assertAlive();
2509
3179
  validateLinkRefs(newInput.tasks, newInput.links);
2510
3180
  detectCycles(newInput.tasks, newInput.links);
2511
- this.#input = newInput;
2512
- this.#taskIndex = buildTaskIndex(newInput.tasks);
2513
- this.#expandedIds = getInitialExpandedIds(newInput.tasks);
3181
+ this.#input = structuredClone(newInput);
3182
+ this.#taskIndex = buildTaskIndex(this.#input.tasks);
3183
+ this.#expandedIds = getInitialExpandedIds(this.#input.tasks);
2514
3184
  if (this.#rafPending && this.#rafId !== null) {
2515
3185
  cancelAnimationFrame(this.#rafId);
2516
3186
  this.#rafId = null;
@@ -2519,26 +3189,78 @@ var GanttChart = class {
2519
3189
  this.#render();
2520
3190
  }
2521
3191
  /**
2522
- * Switches the time scale and re-renders.
3192
+ * Merges the supplied options into the current configuration and re-renders
3193
+ * only the panes affected by the changed options.
2523
3194
  *
2524
- * @param scale - The new {@link TimeScale} to display.
3195
+ * @param opts - A partial {@link GanttOptions} object. Only the keys present
3196
+ * in this parameter are updated; missing keys keep their
3197
+ * previous values.
2525
3198
  * @throws {GanttError} When the instance has been destroyed.
2526
3199
  */
2527
- setScale(scale) {
3200
+ setOptions(opts) {
2528
3201
  this.#assertAlive();
2529
- this.#scale = scale;
2530
- this.#scheduleRender();
3202
+ Object.assign(this.#opts, opts);
3203
+ this.#scale = this.#opts.scale ?? "day";
3204
+ let columnsChanged = false;
3205
+ if (opts.locale !== void 0) {
3206
+ this.#locale = resolveChartLocale(opts.locale);
3207
+ if (this.#opts.gridColumns === void 0) {
3208
+ this.#columns = gridColumnDefaults(this.#locale);
3209
+ this.#leftPaneDefaultWidth = gridNaturalWidth(this.#columns);
3210
+ columnsChanged = true;
3211
+ }
3212
+ if (this.#opts.weekendDays === void 0) this.#weekendDays = normalizeWeekendDays(this.#locale.weekendDays);
3213
+ }
3214
+ if (opts.gridColumns !== void 0) {
3215
+ this.#columns = opts.gridColumns;
3216
+ this.#leftPaneDefaultWidth = this.#opts.leftPaneWidth ?? gridNaturalWidth(this.#columns);
3217
+ columnsChanged = true;
3218
+ }
3219
+ if (columnsChanged && this.#input !== null) this.#rebuildLeftPaneHeader();
3220
+ if (opts.leftPaneWidth !== void 0) this.#leftPaneDefaultWidth = opts.leftPaneWidth;
3221
+ if (opts.height !== void 0) {
3222
+ this.#height = opts.height;
3223
+ this.#root.style.height = `${this.#height}px`;
3224
+ }
3225
+ if (opts.timelineMinWidth !== void 0) {
3226
+ this.#timelineMinWidth = opts.timelineMinWidth;
3227
+ this.#rightPane.style.minWidth = `${this.#timelineMinWidth}px`;
3228
+ }
3229
+ if (opts.weekendDays !== void 0) this.#weekendDays = normalizeWeekendDays(opts.weekendDays);
3230
+ if (opts.specialDays !== void 0) this.#specialDaysByDate = buildSpecialDayIndex(opts.specialDays);
3231
+ if (opts.theme !== void 0) this.#applyTheme();
3232
+ const hasLayoutChange = opts.leftPaneWidth !== void 0 || opts.responsiveSplitPane !== void 0 || opts.mobileBreakpoint !== void 0 || opts.mobileLeftPaneMinWidth !== void 0 || opts.mobileLeftPaneMaxRatio !== void 0 || opts.timelineMinWidth !== void 0;
3233
+ if (hasLayoutChange) this.#applyResponsivePaneStyles();
3234
+ const hasLeftPaneChange = columnsChanged || opts.locale !== void 0;
3235
+ const hasRightPaneChange = opts.scale !== void 0 || opts.showWeekends !== void 0 || opts.weekendDays !== void 0 || opts.specialDays !== void 0 || opts.highlightLinkedDependenciesOnSelect !== void 0 || opts.linkCreationEnabled !== void 0 || opts.progressDragEnabled !== void 0 || opts.viewportStart !== void 0 || opts.viewportEnd !== void 0 || opts.locale !== void 0 || opts.timelineMinWidth !== void 0;
3236
+ if (!(hasLeftPaneChange || hasRightPaneChange || hasLayoutChange)) return;
3237
+ if (this.#rafPending && this.#rafId !== null) {
3238
+ cancelAnimationFrame(this.#rafId);
3239
+ this.#rafId = null;
3240
+ this.#rafPending = false;
3241
+ }
3242
+ if (hasLeftPaneChange && !hasRightPaneChange) this.#renderGrid();
3243
+ else if (!hasLeftPaneChange && hasRightPaneChange) this.#renderTimeline();
3244
+ else this.#render();
2531
3245
  }
2532
3246
  /**
2533
3247
  * Programmatically selects or deselects a task.
2534
3248
  *
2535
3249
  * @param id - The task ID to select, or `null` to clear the selection.
3250
+ * @param fireCallback - Whether to fire the `onTaskClick` callback. Default `true`.
2536
3251
  * @throws {GanttError} When the instance has been destroyed.
2537
3252
  */
2538
- select(id) {
3253
+ select(id, fireCallback = true) {
2539
3254
  this.#assertAlive();
2540
- this.#selectedId = id;
2541
- this.#opts.onSelect?.(id);
3255
+ if (id === null) this.#selectedId = null;
3256
+ else {
3257
+ const task = this.#input?.tasks.find((t) => t.id === id);
3258
+ if (task !== void 0 && fireCallback) this.#callbacks.onTaskClick?.({
3259
+ task,
3260
+ instance: this
3261
+ });
3262
+ this.#selectedId = id;
3263
+ }
2542
3264
  if (this.#rafPending && this.#rafId !== null) {
2543
3265
  cancelAnimationFrame(this.#rafId);
2544
3266
  this.#rafId = null;
@@ -2589,9 +3311,11 @@ var GanttChart = class {
2589
3311
  else window.removeEventListener("resize", this.#applyResponsivePaneStyles);
2590
3312
  if (this.#rafId !== null) cancelAnimationFrame(this.#rafId);
2591
3313
  this.#columnResizeCleanup();
2592
- for (const { cleanupDrag, cleanupLinkHandles } of this.#rightPaneRefs.barRegistry.values()) {
2593
- cleanupDrag();
3314
+ for (const { cleanupDrag, cleanupLinkHandles, cleanupProgressDrag, cleanupTooltip } of this.#rightPaneRefs.barRegistry.values()) {
3315
+ cleanupDrag?.();
2594
3316
  cleanupLinkHandles?.();
3317
+ cleanupProgressDrag?.();
3318
+ cleanupTooltip?.();
2595
3319
  }
2596
3320
  clearChildren(this.#container);
2597
3321
  }
@@ -2606,15 +3330,16 @@ var GanttChart = class {
2606
3330
  ...patch
2607
3331
  };
2608
3332
  }
3333
+ #findTask(id) {
3334
+ return this.#input?.tasks.find((t) => t.id === id);
3335
+ }
2609
3336
  #handleGridClick = (payload) => {
2610
3337
  const now = Date.now();
2611
3338
  const prev = this.#lastGridClick;
2612
3339
  if (prev !== null && prev.id === payload.id && now - prev.atMs <= 350) {
2613
3340
  this.#lastGridClick = null;
2614
- this.#cbs.onTaskEditIntent?.({
3341
+ this.#cbs.onTaskDoubleClick?.({
2615
3342
  id: payload.id,
2616
- source: "grid",
2617
- trigger: "doubleClick",
2618
3343
  task: payload.task
2619
3344
  });
2620
3345
  return;
@@ -2623,7 +3348,7 @@ var GanttChart = class {
2623
3348
  id: payload.id,
2624
3349
  atMs: now
2625
3350
  };
2626
- this.#cbs.onSelect?.(payload.id);
3351
+ this.#cbs.onTaskClick?.(payload.id);
2627
3352
  };
2628
3353
  #onScroll = () => {
2629
3354
  ({scrollTop: this.#scrollTop} = this.#scrollEl);
@@ -2664,6 +3389,7 @@ var GanttChart = class {
2664
3389
  scale: this.#scale,
2665
3390
  highlightLinkedDependenciesOnSelect: this.#opts.highlightLinkedDependenciesOnSelect ?? false,
2666
3391
  linkCreationEnabled: this.#opts.linkCreationEnabled ?? false,
3392
+ progressDragEnabled: this.#opts.progressDragEnabled ?? false,
2667
3393
  expandedIds: this.#expandedIds,
2668
3394
  selectedId: this.#selectedId,
2669
3395
  scrollTop: this.#scrollTop,
@@ -2690,21 +3416,47 @@ var GanttChart = class {
2690
3416
  if (input === null) return;
2691
3417
  const state = this.#computeState(input);
2692
3418
  renderTimeHeader(this.#rightHeader, state);
3419
+ this.#renderGridInternal(state);
3420
+ renderRightPane(this.#rightPaneRefs, state, this.#cbs);
3421
+ };
3422
+ #renderGrid = () => {
3423
+ this.#rafPending = false;
3424
+ const input = this.#input;
3425
+ if (input === null) return;
3426
+ this.#renderGridInternal(this.#computeState(input));
3427
+ };
3428
+ #renderGridInternal(state) {
2693
3429
  renderLeftPane(this.#leftBody, state, {
2694
3430
  onToggle: (id) => {
2695
3431
  if (this.#expandedIds.has(id)) this.#expandedIds.delete(id);
2696
3432
  else this.#expandedIds.add(id);
2697
3433
  this.#scheduleRender();
2698
3434
  },
2699
- onSelect: (id) => this.#cbs.onSelect?.(id),
3435
+ onTaskClick: (id) => this.#cbs.onTaskClick?.(id),
2700
3436
  onRowClick: (payload) => {
2701
3437
  this.#handleGridClick(payload);
2702
3438
  },
2703
- onTaskEditIntent: (payload) => this.#cbs.onTaskEditIntent?.(payload),
2704
- onAdd: (id) => this.#cbs.onAdd?.({ parentId: id })
3439
+ onTaskDoubleClick: (payload) => this.#cbs.onTaskDoubleClick?.(payload),
3440
+ onTaskAdd: (id) => this.#cbs.onTaskAdd?.(id)
2705
3441
  }, this.#columns);
3442
+ }
3443
+ #renderTimeline = () => {
3444
+ this.#rafPending = false;
3445
+ const input = this.#input;
3446
+ if (input === null) return;
3447
+ const state = this.#computeState(input);
3448
+ renderTimeHeader(this.#rightHeader, state);
2706
3449
  renderRightPane(this.#rightPaneRefs, state, this.#cbs);
2707
3450
  };
3451
+ #rebuildLeftPaneHeader() {
3452
+ this.#columnResizeCleanup();
3453
+ clearChildren(this.#leftHeader);
3454
+ const headerEl = buildLeftPaneHeader(this.#columns);
3455
+ this.#leftHeader.append(headerEl);
3456
+ this.#columnResizeCleanup = setupColumnResize(headerEl, this.#leftBody, this.#columns, (updated) => {
3457
+ this.#cbs.onGridColumnsChange?.(updated);
3458
+ });
3459
+ }
2708
3460
  #scheduleRender() {
2709
3461
  if (this.#rafPending || this.#destroyed) return;
2710
3462
  this.#rafPending = true;
@@ -2760,6 +3512,7 @@ var GanttChart = class {
2760
3512
  const headerEl = buildLeftPaneHeader(this.#columns);
2761
3513
  leftHeader.append(headerEl);
2762
3514
  leftPane.append(leftHeader);
3515
+ this.#leftHeader = leftHeader;
2763
3516
  const leftBody = el("div");
2764
3517
  leftPane.append(leftBody);
2765
3518
  this.#leftBody = leftBody;
@@ -2807,12 +3560,14 @@ var GanttChart = class {
2807
3560
  #wireEvents() {
2808
3561
  this.#rightPaneRefs.absoluteLayer.addEventListener("click", (event) => {
2809
3562
  if (event.target.closest(".gantt-bar, .gantt-milestone, .gantt-resize-handle")) return;
2810
- this.#cbs.onSelect?.(null);
3563
+ this.#selectedId = null;
3564
+ this.#scheduleRender();
2811
3565
  });
2812
3566
  this.#root.addEventListener("keydown", (event) => {
2813
3567
  if (event.key === "Escape" && this.#selectedId !== null) {
2814
3568
  event.preventDefault();
2815
- this.#cbs.onSelect?.(null);
3569
+ this.#selectedId = null;
3570
+ this.#scheduleRender();
2816
3571
  }
2817
3572
  });
2818
3573
  this.#scrollEl.addEventListener("scroll", this.#onScroll);
@@ -2827,6 +3582,6 @@ var GanttChart = class {
2827
3582
  }
2828
3583
  };
2829
3584
  //#endregion
2830
- export { BAR_HEIGHT, BAR_Y_OFFSET, CHART_LOCALE_EN_US, DEFAULT_GRID_COLUMNS, DENSITY, EN_US_LABELS, GRID_COLUMN_FR_MIN_WIDTH, GanttChart, GanttError, GanttInputSchema, LinkSchema, LinkTypeSchema, MILESTONE_HALF, MILESTONE_SIZE, ROW_HEIGHT, SCALE_CONFIGS, SpecialDayKindSchema, SpecialDaySchema, TaskSchema, TaskTypeSchema, addDays, addHours, buildTaskTree, computeLayout, createPixelMapper, deriveViewport, deriveWeekNumbering, deriveWeekStartsOn, deriveWeekendDays, detectCycles, diffDays, diffHours, flattenTree, formatLabel, formatWeekNumber, gridColumnDefaults, gridNaturalWidth, gridTemplateColumns, isParent, parseDate, parseGanttInput, resolveChartLocale, routeLinks, safeParseGanttInput, validateLinkRefs, visibleColumns };
3585
+ export { BAR_HEIGHT, BAR_Y_OFFSET, CHART_LOCALE_EN_US, DEFAULT_GRID_COLUMNS, DENSITY, EN_US_LABELS, GRID_COLUMN_FR_MIN_WIDTH, GanttChart, GanttError, GanttInputSchema, LinkSchema, LinkTypeSchema, MILESTONE_HALF, MILESTONE_SIZE, ROW_HEIGHT, SCALE_CONFIGS, SpecialDayKindSchema, SpecialDaySchema, TaskKindSchema, TaskSchema, addDays, addHours, buildTaskTree, computeLayout, createPixelMapper, deriveViewport, deriveWeekNumbering, deriveWeekStartsOn, deriveWeekendDays, detectCycles, diffDays, diffHours, flattenTree, formatLabel, formatWeekNumber, gridColumnDefaults, gridNaturalWidth, gridTemplateColumns, isParent, parseDate, parseGanttInput, resolveChartLocale, routeLinks, validateLinkRefs, visibleColumns };
2831
3586
 
2832
3587
  //# sourceMappingURL=index.mjs.map