gantt-renderer 0.2.0 → 0.3.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,60 @@ 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
+ /** Optional arbitrary metadata for consumer use. Preserved in the parsed output. */
34
+ data: z.record(z.string(), z.unknown()).optional()
35
+ };
36
+ /** @internal */
37
+ const TaskLeafSchema = z.object({
38
+ ...taskBase,
39
+ kind: z.literal("task"),
40
+ /** Duration in hours. Must be positive; use `kind: 'milestone'` for zero-duration points. */
41
+ durationHours: z.number().int().positive(),
42
+ /** 0–100 completion percentage (integer). */
43
+ percentComplete: z.number().int().min(0).max(100).default(0)
44
+ });
45
+ /** @internal */
46
+ const TaskProjectSchema = z.object({
47
+ ...taskBase,
48
+ kind: z.literal("project"),
49
+ /** Duration in hours. Must be positive; use `kind: 'milestone'` for zero-duration points. */
50
+ durationHours: z.number().int().positive(),
51
+ /** 0–100 completion percentage (integer). */
34
52
  percentComplete: z.number().int().min(0).max(100).default(0),
35
53
  /**
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
54
  * Initial expanded state for tree hierarchy.
47
55
  * When `false`, children of this task are hidden on initial render.
48
- * Only relevant for tasks with child tasks.
49
56
  *
50
57
  * @default true
51
58
  */
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()
59
+ open: z.boolean().default(true)
60
+ });
61
+ /** @internal */
62
+ const TaskMilestoneSchema = z.object({
63
+ ...taskBase,
64
+ kind: z.literal("milestone")
55
65
  });
66
+ const TaskSchema = z.discriminatedUnion("kind", [
67
+ TaskLeafSchema,
68
+ TaskProjectSchema,
69
+ TaskMilestoneSchema
70
+ ]);
56
71
  const LinkSchema = z.object({
57
72
  /** Unique positive integer identifier for the dependency link. */
58
73
  id: z.number().int().positive(),
@@ -70,13 +85,64 @@ const LinkSchema = z.object({
70
85
  *
71
86
  * @default 'FS'
72
87
  */
73
- type: LinkTypeSchema.default("FS")
88
+ type: LinkTypeSchema.default("FS"),
89
+ /** Optional arbitrary metadata for consumer use. Preserved in the parsed output. */
90
+ data: z.record(z.string(), z.unknown()).optional()
91
+ }).refine((l) => l.source !== l.target, {
92
+ message: "A link cannot connect a task to itself",
93
+ path: ["target"]
74
94
  });
75
95
  const GanttInputSchema = z.object({
76
96
  /** Array of task objects. At least one task is required. */
77
97
  tasks: z.array(TaskSchema).min(1),
78
98
  /** Optional array of dependency link objects. Defaults to empty array. */
79
99
  links: z.array(LinkSchema).default([])
100
+ }).superRefine((data, ctx) => {
101
+ const taskIds = /* @__PURE__ */ new Set();
102
+ for (let i = 0; i < data.tasks.length; i++) {
103
+ const task = data.tasks[i];
104
+ if (task !== void 0) {
105
+ if (taskIds.has(task.id)) ctx.addIssue({
106
+ code: "custom",
107
+ message: `Duplicate task id: ${task.id}`,
108
+ path: [
109
+ "tasks",
110
+ i,
111
+ "id"
112
+ ]
113
+ });
114
+ taskIds.add(task.id);
115
+ }
116
+ }
117
+ const linkIds = /* @__PURE__ */ new Set();
118
+ for (let i = 0; i < data.links.length; i++) {
119
+ const link = data.links[i];
120
+ if (link !== void 0) {
121
+ if (linkIds.has(link.id)) ctx.addIssue({
122
+ code: "custom",
123
+ message: `Duplicate link id: ${link.id}`,
124
+ path: [
125
+ "links",
126
+ i,
127
+ "id"
128
+ ]
129
+ });
130
+ linkIds.add(link.id);
131
+ }
132
+ }
133
+ const pairKeys = /* @__PURE__ */ new Set();
134
+ for (let i = 0; i < data.links.length; i++) {
135
+ const link = data.links[i];
136
+ if (link !== void 0) {
137
+ const key = `${link.source}:${link.target}`;
138
+ if (pairKeys.has(key)) ctx.addIssue({
139
+ code: "custom",
140
+ message: `Duplicate link pair: source=${link.source} target=${link.target}`,
141
+ path: ["links", i]
142
+ });
143
+ pairKeys.add(key);
144
+ }
145
+ }
80
146
  });
81
147
  /**
82
148
  * Parses raw external data.
@@ -88,18 +154,8 @@ const GanttInputSchema = z.object({
88
154
  function parseGanttInput(raw) {
89
155
  return GanttInputSchema.parse(raw);
90
156
  }
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
157
  //#endregion
102
- //#region src/gantt-chart/errors.ts
158
+ //#region src/lib/errors.ts
103
159
  /**
104
160
  * Domain-specific error with a machine-readable {@link GanttErrorCode}.
105
161
  */
@@ -116,23 +172,67 @@ var GanttError = class extends Error {
116
172
  }
117
173
  };
118
174
  //#endregion
119
- //#region src/gantt-chart/domain/tree.ts
175
+ //#region src/lib/domain/tree.ts
176
+ function detectParentCycles(tasks) {
177
+ const adj = /* @__PURE__ */ new Map();
178
+ for (const task of tasks) {
179
+ adj.set(task.id, []);
180
+ if (task.parent !== void 0) {
181
+ const parents = adj.get(task.parent);
182
+ if (parents !== void 0) parents.push(task.id);
183
+ }
184
+ }
185
+ const WHITE = 0, GRAY = 1, BLACK = 2;
186
+ const color = /* @__PURE__ */ new Map();
187
+ const parent = /* @__PURE__ */ new Map();
188
+ for (const id of adj.keys()) color.set(id, WHITE);
189
+ const dfs = (u) => {
190
+ color.set(u, GRAY);
191
+ for (const v of adj.get(u) ?? []) {
192
+ const vc = color.get(v) ?? WHITE;
193
+ if (vc === GRAY) {
194
+ const path = [v, u];
195
+ let cur = u;
196
+ while (cur !== v) {
197
+ const p = parent.get(cur);
198
+ if (p === void 0) break;
199
+ path.push(p);
200
+ cur = p;
201
+ }
202
+ throw new GanttError("PARENT_CYCLE", `Parent cycle detected: ${[...path].reverse().join(" -> ")}`);
203
+ }
204
+ if (vc === WHITE) {
205
+ parent.set(v, u);
206
+ dfs(v);
207
+ }
208
+ }
209
+ color.set(u, BLACK);
210
+ };
211
+ for (const id of adj.keys()) if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
212
+ }
120
213
  /**
121
214
  * Builds a typed tree from a flat task array.
122
215
  * Order of tasks[] is irrelevant — parents need not precede children.
123
216
  *
124
217
  * @param tasks - The flat array of tasks to convert into a tree.
125
218
  * @returns Root-level {@link TaskNode} instances with populated `children`.
126
- * @throws {GanttError} When a task references a `parent` id that does not exist.
219
+ * @throws {GanttError} When a task references a `parent` id that does not exist,
220
+ * when a parent cycle is detected, or when a `parent` points to a
221
+ * milestone or leaf task.
127
222
  */
128
223
  function buildTaskTree(tasks) {
129
224
  const map = /* @__PURE__ */ new Map();
130
225
  const roots = [];
226
+ for (const task of tasks) if (task.parent !== void 0) {
227
+ const parentTask = tasks.find((t) => t.id === task.parent);
228
+ 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}'`);
229
+ }
131
230
  for (const task of tasks) map.set(task.id, {
132
231
  ...task,
133
232
  children: [],
134
233
  depth: 0
135
234
  });
235
+ detectParentCycles(tasks);
136
236
  for (const task of tasks) {
137
237
  const node = map.get(task.id);
138
238
  if (node === void 0) continue;
@@ -177,7 +277,7 @@ function isParent(node) {
177
277
  return node.children.length > 0;
178
278
  }
179
279
  //#endregion
180
- //#region src/gantt-chart/domain/dependencies.ts
280
+ //#region src/lib/domain/dependencies.ts
181
281
  /**
182
282
  * Detects circular dependencies in the link graph using DFS tri-colour marking.
183
283
  *
@@ -221,21 +321,34 @@ function detectCycles(tasks, links) {
221
321
  for (const id of adj.keys()) if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
222
322
  }
223
323
  /**
224
- * Validates that every link references existing task IDs.
324
+ * Validates that every link references existing task IDs and that no
325
+ * duplicate (source, target) pairs exist.
225
326
  *
226
327
  * @param tasks - The task list (used as the reference set of valid IDs).
227
328
  * @param links - The dependency links to validate.
228
- * @throws {GanttError} When any link references a non-existent source or target task.
329
+ * @throws {GanttError} When any link references a non-existent source or target task,
330
+ * when a non-FS link connects to/from a milestone, or when duplicate
331
+ * (source, target) pairs exist.
229
332
  */
230
333
  function validateLinkRefs(tasks, links) {
231
334
  const ids = new Set(tasks.map((t) => t.id));
335
+ const taskById = new Map(tasks.map((t) => [t.id, t]));
336
+ const pairKeys = /* @__PURE__ */ new Set();
232
337
  for (const link of links) {
233
338
  if (!ids.has(link.source)) throw new GanttError("LINK_REFERENCE", `Link id=${link.id}: source=${link.source} not found`);
234
339
  if (!ids.has(link.target)) throw new GanttError("LINK_REFERENCE", `Link id=${link.id}: target=${link.target} not found`);
340
+ const pairKey = `${link.source}:${link.target}`;
341
+ if (pairKeys.has(pairKey)) throw new GanttError("DUPLICATE_LINK_PAIR", `Link id=${link.id}: duplicate pair source=${link.source} target=${link.target}`);
342
+ pairKeys.add(pairKey);
343
+ if (link.type !== "FS") {
344
+ const sourceTask = taskById.get(link.source);
345
+ const targetTask = taskById.get(link.target);
346
+ 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`);
347
+ }
235
348
  }
236
349
  }
237
350
  //#endregion
238
- //#region src/gantt-chart/locale.ts
351
+ //#region src/lib/locale.ts
239
352
  const WEEK_START_REGION = {
240
353
  US: 0,
241
354
  CA: 0,
@@ -517,7 +630,7 @@ function formatLabel(template, arg) {
517
630
  return template.replaceAll("{0}", arg);
518
631
  }
519
632
  //#endregion
520
- //#region src/gantt-chart/domain/dateMath.ts
633
+ //#region src/lib/domain/dateMath.ts
521
634
  /**
522
635
  * Parses `YYYY-MM-DD` → UTC midnight `Date`.
523
636
  *
@@ -657,7 +770,7 @@ function formatDisplayDate(dateStr, locale) {
657
770
  });
658
771
  }
659
772
  //#endregion
660
- //#region src/gantt-chart/timeline/scale.ts
773
+ //#region src/lib/timeline/scale.ts
661
774
  const H = 36e5;
662
775
  const D = 864e5;
663
776
  const SCALE_CONFIGS = {
@@ -740,7 +853,7 @@ function nextScaleBoundary(date, scale) {
740
853
  }
741
854
  }
742
855
  //#endregion
743
- //#region src/gantt-chart/timeline/pixelMapper.ts
856
+ //#region src/lib/timeline/pixelMapper.ts
744
857
  /**
745
858
  * Creates a stateless pixel mapper for the given scale and viewport start.
746
859
  * All conversions are O(1) arithmetic — safe to call in tight loops.
@@ -773,7 +886,7 @@ function createPixelMapper(scale, viewportStart) {
773
886
  };
774
887
  }
775
888
  //#endregion
776
- //#region src/gantt-chart/timeline/layoutEngine.ts
889
+ //#region src/lib/timeline/layoutEngine.ts
777
890
  const DENSITY = {
778
891
  rowHeight: 44,
779
892
  barHeight: 28,
@@ -802,8 +915,7 @@ function computeLayout(rows, mapper) {
802
915
  const x = mapper.toX(start);
803
916
  const y = i * ROW_HEIGHT + BAR_Y_OFFSET;
804
917
  const centerY = i * ROW_HEIGHT + ROW_HEIGHT / 2;
805
- const type = task.type ?? "task";
806
- if (type === "milestone") {
918
+ if (task.kind === "milestone") {
807
919
  result.set(task.id, {
808
920
  taskId: task.id,
809
921
  x,
@@ -811,7 +923,7 @@ function computeLayout(rows, mapper) {
811
923
  width: 0,
812
924
  height: BAR_HEIGHT,
813
925
  progressWidth: 0,
814
- type: "milestone",
926
+ kind: "milestone",
815
927
  rowIndex: i,
816
928
  centerX: x,
817
929
  centerY
@@ -827,7 +939,7 @@ function computeLayout(rows, mapper) {
827
939
  width,
828
940
  height: BAR_HEIGHT,
829
941
  progressWidth,
830
- type,
942
+ kind: task.kind,
831
943
  rowIndex: i,
832
944
  centerX: x + width / 2,
833
945
  centerY
@@ -860,99 +972,171 @@ function deriveViewport(tasks, paddingHours = 48) {
860
972
  let maxMs = -Infinity;
861
973
  for (const task of tasks) {
862
974
  const start = parseDate(task.startDate);
863
- const end = addHours(start, task.durationHours);
864
975
  if (start.getTime() < minMs) minMs = start.getTime();
865
- if (end.getTime() > maxMs) maxMs = end.getTime();
976
+ if (task.kind !== "milestone") {
977
+ const end = addHours(start, task.durationHours);
978
+ if (end.getTime() > maxMs) maxMs = end.getTime();
979
+ } else if (start.getTime() > maxMs) maxMs = start.getTime();
866
980
  }
867
981
  return [addHours(new Date(minMs), -paddingHours), addHours(new Date(maxMs), paddingHours)];
868
982
  }
869
983
  //#endregion
870
- //#region src/gantt-chart/rendering/linkRouter.ts
871
- const TURN_MARGIN = 12;
984
+ //#region src/lib/rendering/linkRouter.ts
985
+ /** px gap before/after bar for routing clearance and arrow approach */
986
+ const TURN_MARGIN = 24;
987
+ /** px vertical offset below the bar row for same-row loop detours */
988
+ const SAME_ROW_DETOUR = 24;
989
+ /** Segments shorter than this (px) are collapsed before stroking */
990
+ const STUB_THRESHOLD = 2;
872
991
  /**
873
- * Produces the vertex list for an orthogonal connector between src and tgt.
992
+ * Removes consecutive points whose Euclidean distance is below {@link STUB_THRESHOLD}.
993
+ * The first point is always kept. This prevents near‑zero‑length segments from
994
+ * appearing as visible stubs near the arrowhead.
874
995
  *
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
996
+ * @param points - The ordered vertex list.
997
+ * @returns A filtered copy, guaranteed to contain at least the first point.
998
+ */
999
+ function collapseStubs(points) {
1000
+ const out = [];
1001
+ for (const pt of points) {
1002
+ const last = out.at(-1);
1003
+ if (last === void 0 || Math.hypot(pt.x - last.x, pt.y - last.y) >= STUB_THRESHOLD) out.push(pt);
1004
+ }
1005
+ return out;
1006
+ }
1007
+ /**
1008
+ * True when the dependency arrow enters the target on its **left** edge (FS, SS).
1009
+ * False when the arrow enters on the target's **right** edge (FF, SF).
1010
+ *
1011
+ * The arrowhead uses SVG `orient="auto"` so it rotates to match the direction
1012
+ * of the **last** path segment. Therefore:
1013
+ *
1014
+ * - Left-entry → last segment must travel **RIGHT** (penultimate.x < tx).
1015
+ * - Right-entry → last segment must travel **LEFT** (penultimate.x > tx).
1016
+ *
1017
+ * @param type - The link type.
1018
+ * @returns `true` for FS / SS, `false` for FF / SF.
1019
+ */
1020
+ function isLeftEntry(type) {
1021
+ return type === "FS" || type === "SS";
1022
+ }
1023
+ /**
1024
+ * True when the link exits the source bar on its **right** edge (FS, FF).
1025
+ * False when it exits on the **left** edge (SS, SF).
1026
+ *
1027
+ * The first step after the source anchor should move **away** from the bar,
1028
+ * **not** into it. This means:
1029
+ *
1030
+ * - Exit‑right → first horizontal segment goes RIGHT (+TURN_MARGIN).
1031
+ * - Exit‑left → first horizontal segment goes LEFT (-TURN_MARGIN).
1032
+ *
1033
+ * @param type - The link type.
1034
+ * @returns `true` for FS / FF, `false` for SS / SF.
1035
+ */
1036
+ function isExitRight(type) {
1037
+ return type === "FS" || type === "FF";
1038
+ }
1039
+ /**
1040
+ * Computes anchor points for the given link type.
1041
+ *
1042
+ * | Type | Source anchor (`sx`) | Target anchor (`tx`) |
1043
+ * |------|-----------------------------|-------------------------------|
1044
+ * | FS | right edge of source | left edge of target |
1045
+ * | SS | left edge of source | left edge of target |
1046
+ * | FF | right edge of source | right edge of target |
1047
+ * | SF | left edge of source | right edge of target |
1048
+ *
1049
+ * Milestone offsets are applied automatically: ± {@link MILESTONE_HALF} replaces
1050
+ * ± width for zero‑width milestones.
880
1051
  *
881
1052
  * @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.
1053
+ * @param src - The source bar layout.
1054
+ * @param tgt - The target bar layout.
1055
+ * @returns Anchor x coordinates `{sx, tx}`.
1056
+ * @throws {Error} if the link type is not handled (exhaustiveness guard).
885
1057
  */
886
- function route(type, src, tgt) {
887
- let sx, tx;
888
- const sy = src.centerY;
889
- const ty = tgt.centerY;
1058
+ function getAnchors(type, src, tgt) {
1059
+ const srcRight = src.kind === "milestone" ? src.x + MILESTONE_HALF : src.x + src.width;
1060
+ const srcLeft = src.kind === "milestone" ? src.x - MILESTONE_HALF : src.x;
1061
+ const tgtRight = tgt.kind === "milestone" ? tgt.x + MILESTONE_HALF : tgt.x + tgt.width;
1062
+ const tgtLeft = tgt.kind === "milestone" ? tgt.x - MILESTONE_HALF : tgt.x;
890
1063
  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
- ];
1064
+ case "FS": return {
1065
+ sx: srcRight,
1066
+ tx: tgtLeft
1067
+ };
1068
+ case "SS": return {
1069
+ sx: srcLeft,
1070
+ tx: tgtLeft
1071
+ };
1072
+ case "FF": return {
1073
+ sx: srcRight,
1074
+ tx: tgtRight
1075
+ };
1076
+ case "SF": return {
1077
+ sx: srcLeft,
1078
+ tx: tgtRight
1079
+ };
1080
+ default: throw new Error(`Unhandled link type: ${String(type)}`);
1081
+ }
1082
+ }
1083
+ /**
1084
+ * Routes a link whose source and target rows are within 1 px of each other.
1085
+ *
1086
+ * **Direct‑line optimisation**
1087
+ * A plain horizontal segment is only used when it is non‑degenerate (`sx ≠ tx`)
1088
+ * AND the arrowhead direction is visually correct:
1089
+ *
1090
+ * | Entry side | Condition | Arrow direction |
1091
+ * |-----------|-----------|----------------|
1092
+ * | left | `sx < tx` | → RIGHT ✓ |
1093
+ * | right | `sx > tx` | ← LEFT ✓ |
1094
+ *
1095
+ * Otherwise a 6‑vertex detour is drawn so that the last segment approaches
1096
+ * the target from the correct side. By default the detour goes **below** the
1097
+ * bars; pass `above = true` when headroom is insufficient below.
1098
+ *
1099
+ * @param sx - Source anchor x.
1100
+ * @param sy - Source row center y.
1101
+ * @param tx - Target anchor x.
1102
+ * @param ty - Target row center y.
1103
+ * @param leftEntry - Whether the link enters the target on its left edge.
1104
+ * @param exitRight - Whether the link exits the source on its right edge.
1105
+ * @param above - Route the detour above the bar row instead of below (default `false`).
1106
+ * @returns An ordered array of {@link Point} vertices.
1107
+ */
1108
+ function routeSameRow(sx, sy, tx, ty, leftEntry, exitRight, above = false) {
1109
+ if (Math.abs(sx - tx) >= STUB_THRESHOLD) {
1110
+ if (leftEntry && sx < tx || !leftEntry && sx > tx) return [{
1111
+ x: sx,
1112
+ y: sy
1113
+ }, {
1114
+ x: tx,
1115
+ y: ty
1116
+ }];
935
1117
  }
936
- const loopX = tx - TURN_MARGIN;
1118
+ const exitDir = exitRight ? TURN_MARGIN : -TURN_MARGIN;
1119
+ const detourY = sy + (above ? -SAME_ROW_DETOUR : SAME_ROW_DETOUR);
1120
+ const approachX = leftEntry ? tx - TURN_MARGIN : tx + TURN_MARGIN;
937
1121
  return [
938
1122
  {
939
1123
  x: sx,
940
1124
  y: sy
941
1125
  },
942
1126
  {
943
- x: sx + TURN_MARGIN,
1127
+ x: sx + exitDir,
944
1128
  y: sy
945
1129
  },
946
1130
  {
947
- x: sx + TURN_MARGIN,
948
- y: (sy + ty) / 2
1131
+ x: sx + exitDir,
1132
+ y: detourY
949
1133
  },
950
1134
  {
951
- x: loopX,
952
- y: (sy + ty) / 2
1135
+ x: approachX,
1136
+ y: detourY
953
1137
  },
954
1138
  {
955
- x: loopX,
1139
+ x: approachX,
956
1140
  y: ty
957
1141
  },
958
1142
  {
@@ -962,11 +1146,135 @@ function route(type, src, tgt) {
962
1146
  ];
963
1147
  }
964
1148
  /**
1149
+ * Routes a link between **different** rows using an orthogonal path.
1150
+ *
1151
+ * 1. Step **away** from the source bar to the crossover x (`crossX`).
1152
+ * 2. Travel **vertically** to the midpoint between rows (`midY`).
1153
+ * 3. Travel **horizontally** to the approach point on the correct side
1154
+ * of the target.
1155
+ * 4. Travel **vertically** to the target row (`ty`).
1156
+ * 5. Final segment to the target entry point (`tx`).
1157
+ *
1158
+ * The crossover x is clamped so the path never doubles back past both bars.
1159
+ * When exit and entry are on the **same** side (SS / FF) the exit-side step
1160
+ * is limited to the approach x, avoiding a wide U‑shape.
1161
+ *
1162
+ * The approach point is chosen so the last segment travels in the arrow
1163
+ * direction demanded by the entry side:
1164
+ *
1165
+ * - Left‑entry (FS, SS): approach from the **left** → `tx - TURN_MARGIN`
1166
+ * Last segment goes RIGHT.
1167
+ * - Right‑entry (FF, SF): approach from the **right** → `tx + TURN_MARGIN`
1168
+ * Last segment goes LEFT.
1169
+ *
1170
+ * left‑entry (FS / SS) right‑entry (FF / SF)
1171
+ * ───────────────────── ─────────────────────
1172
+ * sx ●────────────────► sx ●────────────────►
1173
+ * exitDir exitDir
1174
+ * │ │
1175
+ * │ midY │ midY
1176
+ * ▼ ════════════════════► ▼ ════════════════════►
1177
+ * │ │
1178
+ * │ │
1179
+ * ▼ approachFromLeft ▼ approachFromRight
1180
+ * ●────────────────► ◄────────────────●
1181
+ * tx tx
1182
+ *
1183
+ * @param sx - Source anchor x.
1184
+ * @param sy - Source row center y.
1185
+ * @param tx - Target anchor x.
1186
+ * @param ty - Target row center y.
1187
+ * @param leftEntry - Whether the link enters the target on its left edge.
1188
+ * @param exitRight - Whether the link exits the source on its right edge.
1189
+ * @returns An ordered array of {@link Point} vertices.
1190
+ */
1191
+ function routeMultiRow(sx, sy, tx, ty, leftEntry, exitRight) {
1192
+ const midY = Math.round(Math.abs(sy - ty) / ROW_HEIGHT) % 2 === 0 ? (sy + ty) / 2 + ROW_HEIGHT / 2 : (sy + ty) / 2;
1193
+ const approachX = leftEntry ? tx - TURN_MARGIN : tx + TURN_MARGIN;
1194
+ const crossX = exitRight ? Math.max(sx + TURN_MARGIN, approachX) : Math.min(sx - TURN_MARGIN, approachX);
1195
+ return [
1196
+ {
1197
+ x: sx,
1198
+ y: sy
1199
+ },
1200
+ {
1201
+ x: crossX,
1202
+ y: sy
1203
+ },
1204
+ {
1205
+ x: crossX,
1206
+ y: midY
1207
+ },
1208
+ {
1209
+ x: approachX,
1210
+ y: midY
1211
+ },
1212
+ {
1213
+ x: approachX,
1214
+ y: ty
1215
+ },
1216
+ {
1217
+ x: tx,
1218
+ y: ty
1219
+ }
1220
+ ];
1221
+ }
1222
+ /**
1223
+ * Produces the vertex list for an orthogonal connector between source and target.
1224
+ *
1225
+ * ## Anchor points (sx / tx)
1226
+ *
1227
+ * | Type | Source anchor (`sx`) | Target anchor (`tx`) |
1228
+ * |------|-----------------------------|-------------------------------|
1229
+ * | FS | right edge of source | left edge of target |
1230
+ * | SS | left edge of source | left edge of target |
1231
+ * | FF | right edge of source | right edge of target |
1232
+ * | SF | left edge of source | right edge of target |
1233
+ *
1234
+ * Milestone offsets are applied automatically: ± {@link MILESTONE_HALF} replaces
1235
+ * ± width for zero‑width milestones.
1236
+ *
1237
+ * ## Routing strategy
1238
+ *
1239
+ * **Same row** (|sy − ty| < 1 px):
1240
+ * - Direct horizontal line when non‑degenerate **and** the arrow direction
1241
+ * naturally points **into** the target (see {@link routeSameRow}).
1242
+ * - Otherwise a 6‑vertex detour below the bars is drawn.
1243
+ *
1244
+ * **Different rows**: always a 6‑vertex orthogonal path that steps away from
1245
+ * the source, passes through the midpoint between rows, and approaches the
1246
+ * target from the correct side (see {@link routeMultiRow}).
1247
+ *
1248
+ * ## Arrowhead direction guarantee
1249
+ *
1250
+ * The SVG `marker-end` uses `orient="auto"`, so the arrow rotates to match
1251
+ * the last segment. This function ensures the last segment always travels
1252
+ * **into** the target on the semantically correct edge:
1253
+ *
1254
+ * | Entry side | Target edge | Last segment direction |
1255
+ * |-----------|-------------|-----------------------|
1256
+ * | left | left edge | → RIGHT |
1257
+ * | right | right edge | ← LEFT |
1258
+ *
1259
+ * @param type - The link type determining start/end anchor points.
1260
+ * @param src - The source bar layout.
1261
+ * @param tgt - The target bar layout.
1262
+ * @returns An ordered array of {@link Point} vertices.
1263
+ */
1264
+ function route(type, src, tgt) {
1265
+ const { sx, tx } = getAnchors(type, src, tgt);
1266
+ const sy = src.centerY;
1267
+ const ty = tgt.centerY;
1268
+ const leftEntry = isLeftEntry(type);
1269
+ const exitRight = isExitRight(type);
1270
+ return collapseStubs(Math.abs(sy - ty) < 1 ? routeSameRow(sx, sy, tx, ty, leftEntry, exitRight) : routeMultiRow(sx, sy, tx, ty, leftEntry, exitRight));
1271
+ }
1272
+ /**
965
1273
  * Computes orthogonal routing for all dependency links.
966
1274
  * Links whose source or target is not in the layout map are skipped silently
967
1275
  * (e.g. when the row is collapsed).
968
1276
  *
969
- * @param links - The dependency links to route.
1277
+ * @param links - The dependency links to route.
970
1278
  * @param layouts - A map from task ID to its computed {@link BarLayout}.
971
1279
  * @returns An array of {@link RoutedLink} objects with computed vertex paths.
972
1280
  */
@@ -979,12 +1287,13 @@ function routeLinks(links, layouts) {
979
1287
  linkId: link.id,
980
1288
  sourceTaskId: link.source,
981
1289
  targetTaskId: link.target,
1290
+ type: link.type,
982
1291
  points: route(link.type, src, tgt)
983
1292
  };
984
1293
  }).filter((r) => r !== null);
985
1294
  }
986
1295
  //#endregion
987
- //#region src/gantt-chart/vanilla/dom/helpers.ts
1296
+ //#region src/lib/vanilla/dom/helpers.ts
988
1297
  /**
989
1298
  * Batches style assignments; avoids repeated style recalculations.
990
1299
  *
@@ -1030,7 +1339,7 @@ function setAttrs(elem, attrs) {
1030
1339
  for (const [k, v] of Object.entries(attrs)) elem.setAttribute(k, String(v));
1031
1340
  }
1032
1341
  //#endregion
1033
- //#region src/gantt-chart/vanilla/dom/timeHeader.ts
1342
+ //#region src/lib/vanilla/dom/timeHeader.ts
1034
1343
  function specialDayKind(date, specialDaysByDate, showWeekends, weekendDays) {
1035
1344
  const dateKey = startOfDay(date).toISOString().slice(0, 10);
1036
1345
  const specialDay = specialDaysByDate.get(dateKey);
@@ -1168,7 +1477,7 @@ function renderTimeHeader(container, state) {
1168
1477
  container.append(lowerRow);
1169
1478
  }
1170
1479
  //#endregion
1171
- //#region src/gantt-chart/vanilla/dom/gridColumns.ts
1480
+ //#region src/lib/vanilla/dom/gridColumns.ts
1172
1481
  const DEFAULT_GRID_COLUMNS = [
1173
1482
  {
1174
1483
  id: "name",
@@ -1249,8 +1558,8 @@ function visibleColumns(columns) {
1249
1558
  return columns.filter((c) => c.visible !== false);
1250
1559
  }
1251
1560
  const GRID_COLUMN_FR_MIN_WIDTH = 120;
1252
- const PX_RE = /^(\d+(?:\.\d+)?)px$/;
1253
- const FR_RE = /^(\d+(?:\.\d+)?)fr$/;
1561
+ const PX_RE = /^(\d+(?:\.\d+)?)px$/u;
1562
+ const FR_RE = /^(\d+(?:\.\d+)?)fr$/u;
1254
1563
  function parseColumnMinWidth(width) {
1255
1564
  const trimmed = width.trim();
1256
1565
  const pxMatch = PX_RE.exec(trimmed);
@@ -1272,21 +1581,45 @@ function gridNaturalWidth(columns) {
1272
1581
  return total;
1273
1582
  }
1274
1583
  //#endregion
1275
- //#region src/gantt-chart/vanilla/dom/leftPane.ts
1584
+ //#region src/lib/vanilla/dom/leftPane.ts
1276
1585
  const INDENT = 16;
1277
1586
  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 }
1587
+ function toTask$1(node) {
1588
+ const base = {
1589
+ id: node.id,
1590
+ text: node.text,
1591
+ startDate: node.startDate,
1592
+ ...node.parent === void 0 ? {} : { parent: node.parent },
1593
+ ...node.color === void 0 ? {} : { color: node.color },
1594
+ ...node.data === void 0 ? {} : { data: node.data }
1289
1595
  };
1596
+ switch (node.kind) {
1597
+ case "task": return {
1598
+ ...base,
1599
+ kind: "task",
1600
+ durationHours: node.durationHours,
1601
+ percentComplete: node.percentComplete
1602
+ };
1603
+ case "project": return {
1604
+ ...base,
1605
+ kind: "project",
1606
+ durationHours: node.durationHours,
1607
+ percentComplete: node.percentComplete,
1608
+ open: node.open
1609
+ };
1610
+ case "milestone": return {
1611
+ ...base,
1612
+ kind: "milestone"
1613
+ };
1614
+ }
1615
+ }
1616
+ function getTaskField(task, field) {
1617
+ switch (field) {
1618
+ case "durationHours": return task.kind !== "milestone" ? task.durationHours : void 0;
1619
+ case "percentComplete": return task.kind !== "milestone" ? task.percentComplete : void 0;
1620
+ case "open": return task.kind === "project" ? task.open : void 0;
1621
+ default: return task[field];
1622
+ }
1290
1623
  }
1291
1624
  function buildTreeNameCell(row, expandedIds, cbs) {
1292
1625
  const hasChildren = isParent(row);
@@ -1330,7 +1663,7 @@ function buildTreeNameCell(row, expandedIds, cbs) {
1330
1663
  const label = el("span");
1331
1664
  css(label, {
1332
1665
  fontSize: "var(--gantt-font-size-md)",
1333
- fontWeight: row.type === "project" ? "var(--gantt-font-weight-bold)" : "var(--gantt-font-weight-normal)",
1666
+ fontWeight: row.kind === "project" ? "var(--gantt-font-weight-bold)" : "var(--gantt-font-weight-normal)",
1334
1667
  color: "var(--gantt-text)",
1335
1668
  overflow: "hidden",
1336
1669
  textOverflow: "ellipsis",
@@ -1354,9 +1687,9 @@ function buildDataCell(row, column, locale) {
1354
1687
  css(cell, styles);
1355
1688
  const task = toTask$1(row);
1356
1689
  if (column.field !== void 0) {
1357
- const rawValue = task[column.field];
1690
+ const rawValue = getTaskField(task, column.field);
1358
1691
  if (column.format !== void 0) cell.textContent = column.format(rawValue, task, row, locale);
1359
- else cell.textContent = rawValue !== null && rawValue !== void 0 ? String(rawValue) : "";
1692
+ else cell.textContent = rawValue !== null && rawValue !== void 0 ? typeof rawValue === "object" ? JSON.stringify(rawValue) : String(rawValue) : "";
1360
1693
  }
1361
1694
  return cell;
1362
1695
  }
@@ -1375,7 +1708,7 @@ function buildAddButton(row, cbs, locale) {
1375
1708
  });
1376
1709
  btn.addEventListener("click", (event) => {
1377
1710
  event.stopPropagation();
1378
- cbs.onAdd(row.id);
1711
+ cbs.onTaskAdd(row.id);
1379
1712
  });
1380
1713
  return btn;
1381
1714
  }
@@ -1415,7 +1748,7 @@ function buildRow(row, selectedId, expandedIds, cbs, columns, locale) {
1415
1748
  wrapper.addEventListener("keydown", (event) => {
1416
1749
  if (event.key === "Enter" || event.key === " ") {
1417
1750
  event.preventDefault();
1418
- cbs.onSelect(row.id);
1751
+ cbs.onTaskSelect(row.id);
1419
1752
  }
1420
1753
  });
1421
1754
  for (const column of visibleColumns(columns)) wrapper.append(buildCell(column, row, expandedIds, cbs, locale));
@@ -1566,23 +1899,22 @@ function setupColumnResize(headerEl, bodyEl, columns, onChange) {
1566
1899
  };
1567
1900
  }
1568
1901
  //#endregion
1569
- //#region src/gantt-chart/vanilla/dom/dependencyLayer.ts
1902
+ //#region src/lib/vanilla/dom/dependencyLayer.ts
1570
1903
  const NS = "http://www.w3.org/2000/svg";
1904
+ const ARROW_PATH = "M 0 1 L 10 5 L 0 9 Z";
1571
1905
  const ARROW_SIZE = 6;
1572
1906
  /**
1573
1907
  * Creates the SVG overlay element. Call once; pass to updateDependencyLayer on each render.
1574
1908
  * Also creates a hidden ghost-line path used during link-creation drags.
1575
1909
  *
1576
- * @param totalWidth - The total pixel width of the SVG viewport.
1577
- * @param totalHeight - The total pixel height of the SVG viewport.
1910
+ * The SVG is initially zero-sized; `updateDependencyLayer` sets width/height each frame.
1911
+ *
1912
+ * @param _totalWidth - The total pixel width of the SVG viewport.
1913
+ * @param _totalHeight - The total pixel height of the SVG viewport.
1578
1914
  * @returns An `SVGSVGElement` ready to be inserted into the DOM.
1579
1915
  */
1580
- function createDependencyLayer(totalWidth, totalHeight) {
1916
+ function createDependencyLayer(_totalWidth, _totalHeight) {
1581
1917
  const svg = document.createElementNS(NS, "svg");
1582
- setAttrs(svg, {
1583
- width: totalWidth,
1584
- height: totalHeight
1585
- });
1586
1918
  Object.assign(svg.style, {
1587
1919
  position: "absolute",
1588
1920
  top: "0",
@@ -1597,7 +1929,7 @@ function createDependencyLayer(totalWidth, totalHeight) {
1597
1929
  setAttrs(marker, {
1598
1930
  id,
1599
1931
  viewBox: "0 0 10 10",
1600
- refX: "9",
1932
+ refX: "10",
1601
1933
  refY: "5",
1602
1934
  markerWidth: ARROW_SIZE,
1603
1935
  markerHeight: ARROW_SIZE,
@@ -1605,7 +1937,7 @@ function createDependencyLayer(totalWidth, totalHeight) {
1605
1937
  });
1606
1938
  const path = document.createElementNS(NS, "path");
1607
1939
  setAttrs(path, {
1608
- d: "M 0 1 L 10 5 L 0 9 Z",
1940
+ d: ARROW_PATH,
1609
1941
  fill: color
1610
1942
  });
1611
1943
  marker.append(path);
@@ -1638,10 +1970,9 @@ function createDependencyLayer(totalWidth, totalHeight) {
1638
1970
  function showGhostLine(svg, x1, y1, x2, y2, valid) {
1639
1971
  const ghost = svg.querySelector("path.gantt-ghost-line");
1640
1972
  if (ghost === null) return;
1641
- setAttrs(ghost, {
1642
- d: `M ${x1},${y1} L ${x2},${y2}`,
1643
- "stroke-dasharray": valid ? "none" : "5 3"
1644
- });
1973
+ setAttrs(ghost, { d: `M ${x1},${y1} L ${x2},${y2}` });
1974
+ if (valid) ghost.removeAttribute("stroke-dasharray");
1975
+ else ghost.setAttribute("stroke-dasharray", "5 3");
1645
1976
  if (valid) ghost.setAttribute("marker-end", "url(#gantt-arrow)");
1646
1977
  else ghost.removeAttribute("marker-end");
1647
1978
  ghost.style.display = "";
@@ -1668,26 +1999,21 @@ function hideGhostLine(svg) {
1668
1999
  * @param totalHeight - The total pixel height of the SVG viewport.
1669
2000
  * @param selectedTaskId - The currently selected task ID, or `null`.
1670
2001
  * @param highlightLinkedDependenciesOnSelect - When `true`, links connected to the selected task use highlight styling.
2002
+ * @param cbs - Optional callbacks for link click and double-click events.
1671
2003
  */
1672
- function updateDependencyLayer(svg, links, totalWidth, totalHeight, selectedTaskId, highlightLinkedDependenciesOnSelect) {
2004
+ function updateDependencyLayer(svg, links, totalWidth, totalHeight, selectedTaskId, highlightLinkedDependenciesOnSelect, cbs) {
1673
2005
  setAttrs(svg, {
1674
2006
  width: totalWidth,
1675
2007
  height: totalHeight
1676
2008
  });
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
- }
2009
+ const toRemove = [...svg.children].slice(1).filter((c) => !c.classList.contains("gantt-ghost-line"));
1682
2010
  for (const node of toRemove) svg.removeChild(node);
2011
+ const ghost = svg.querySelector("path.gantt-ghost-line");
1683
2012
  for (const link of links) {
1684
2013
  const { points } = link;
1685
2014
  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
- }
2015
+ const [first, ...rest] = points;
2016
+ const d = `M ${first.x},${first.y}${rest.map((p) => ` L ${p.x},${p.y}`).join("")}`;
1691
2017
  const isRelated = highlightLinkedDependenciesOnSelect && selectedTaskId !== null && (link.sourceTaskId === selectedTaskId || link.targetTaskId === selectedTaskId);
1692
2018
  const path = document.createElementNS(NS, "path");
1693
2019
  setAttrs(path, {
@@ -1696,25 +2022,61 @@ function updateDependencyLayer(svg, links, totalWidth, totalHeight, selectedTask
1696
2022
  stroke: isRelated ? "var(--gantt-link-hi)" : "var(--gantt-link)",
1697
2023
  "stroke-width": isRelated ? "1.8" : "1.5",
1698
2024
  "stroke-linejoin": "round",
1699
- "marker-end": isRelated ? "url(#gantt-arrow-hi)" : "url(#gantt-arrow)"
2025
+ "marker-end": isRelated ? "url(#gantt-arrow-hi)" : "url(#gantt-arrow)",
2026
+ "data-link-id": String(link.linkId)
1700
2027
  });
1701
- svg.append(path);
2028
+ path.style.pointerEvents = "visibleStroke";
2029
+ path.style.cursor = "pointer";
2030
+ path.addEventListener("click", (event) => {
2031
+ if (event.detail === 1) cbs?.onLinkClick?.({
2032
+ id: link.linkId,
2033
+ source: link.sourceTaskId,
2034
+ target: link.targetTaskId,
2035
+ type: link.type
2036
+ });
2037
+ });
2038
+ path.addEventListener("dblclick", (_event) => {
2039
+ cbs?.onLinkDblClick?.({
2040
+ id: link.linkId,
2041
+ source: link.sourceTaskId,
2042
+ target: link.targetTaskId,
2043
+ type: link.type
2044
+ });
2045
+ });
2046
+ if (ghost !== null) svg.insertBefore(path, ghost);
2047
+ else svg.append(path);
1702
2048
  }
1703
2049
  }
1704
2050
  //#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 }
2051
+ //#region src/lib/vanilla/interaction/drag.ts
2052
+ function toTask(node) {
2053
+ const base = {
2054
+ id: node.id,
2055
+ text: node.text,
2056
+ startDate: node.startDate,
2057
+ ...node.parent === void 0 ? {} : { parent: node.parent },
2058
+ ...node.color === void 0 ? {} : { color: node.color },
2059
+ ...node.data === void 0 ? {} : { data: node.data }
1717
2060
  };
2061
+ switch (node.kind) {
2062
+ case "task": return {
2063
+ ...base,
2064
+ kind: "task",
2065
+ durationHours: node.durationHours,
2066
+ percentComplete: node.percentComplete
2067
+ };
2068
+ case "project": return {
2069
+ ...base,
2070
+ kind: "project",
2071
+ durationHours: node.durationHours,
2072
+ percentComplete: node.percentComplete,
2073
+ open: node.open
2074
+ };
2075
+ case "milestone": return {
2076
+ ...base,
2077
+ kind: "milestone"
2078
+ };
2079
+ }
1718
2080
  }
1719
2081
  /**
1720
2082
  * Attaches drag-to-move and resize listeners to a bar element.
@@ -1736,22 +2098,27 @@ function attachDrag(barEl, resizeHandleEl, task, getMapper, cbs) {
1736
2098
  try {
1737
2099
  barEl.setPointerCapture(e.pointerId);
1738
2100
  } catch {}
1739
- cbs.onSelect?.(task.id);
2101
+ cbs.onTaskSelect?.(task.id);
1740
2102
  const startX = e.clientX;
1741
2103
  const originDate = parseDate(task.startDate);
1742
2104
  const mapper = getMapper();
2105
+ let lastHours = 0;
1743
2106
  function onMove(me) {
1744
2107
  const dx = me.clientX - startX;
1745
- const hours = Math.round(mapper.widthToDuration(dx));
1746
- cbs.onMove?.({
2108
+ lastHours = Math.round(mapper.widthToDuration(dx));
2109
+ cbs.onTaskMove?.({
1747
2110
  id: task.id,
1748
- startDate: addHours(originDate, hours)
2111
+ startDate: addHours(originDate, lastHours)
1749
2112
  });
1750
2113
  }
1751
2114
  function onUp() {
1752
2115
  window.removeEventListener("pointermove", onMove);
1753
2116
  window.removeEventListener("pointerup", onUp);
1754
2117
  barEl.style.cursor = "grab";
2118
+ cbs._onTaskMoveFinal?.({
2119
+ id: task.id,
2120
+ startDate: addHours(originDate, lastHours)
2121
+ });
1755
2122
  }
1756
2123
  barEl.style.cursor = "grabbing";
1757
2124
  window.addEventListener("pointermove", onMove);
@@ -1765,29 +2132,33 @@ function attachDrag(barEl, resizeHandleEl, task, getMapper, cbs) {
1765
2132
  resizeHandleEl.setPointerCapture(e.pointerId);
1766
2133
  } catch {}
1767
2134
  const startX = e.clientX;
1768
- const origDur = task.durationHours;
2135
+ const origDur = task.kind !== "milestone" ? task.durationHours : 0;
1769
2136
  const mapper = getMapper();
2137
+ let lastDuration = origDur;
1770
2138
  function onMove(me) {
1771
2139
  const dx = me.clientX - startX;
1772
2140
  const hoursDelta = Math.round(mapper.widthToDuration(dx));
1773
- cbs.onResize?.({
2141
+ lastDuration = Math.max(1, origDur + hoursDelta);
2142
+ cbs.onTaskResize?.({
1774
2143
  id: task.id,
1775
- durationHours: Math.max(1, origDur + hoursDelta)
2144
+ durationHours: lastDuration
1776
2145
  });
1777
2146
  }
1778
2147
  function onUp() {
1779
2148
  window.removeEventListener("pointermove", onMove);
1780
2149
  window.removeEventListener("pointerup", onUp);
2150
+ cbs._onTaskResizeFinal?.({
2151
+ id: task.id,
2152
+ durationHours: lastDuration
2153
+ });
1781
2154
  }
1782
2155
  window.addEventListener("pointermove", onMove);
1783
2156
  window.addEventListener("pointerup", onUp);
1784
2157
  }
1785
2158
  function onBarClick(event) {
1786
2159
  if (event.detail !== 2) return;
1787
- cbs.onTaskEditIntent?.({
2160
+ cbs.onTaskDoubleClick?.({
1788
2161
  id: task.id,
1789
- source: "bar",
1790
- trigger: "doubleClick",
1791
2162
  task: toTask(task)
1792
2163
  });
1793
2164
  }
@@ -1801,6 +2172,56 @@ function attachDrag(barEl, resizeHandleEl, task, getMapper, cbs) {
1801
2172
  };
1802
2173
  }
1803
2174
  /**
2175
+ * Attaches drag-to-change-progress listeners to a progress overlay element.
2176
+ *
2177
+ * @param progressEl - The progress overlay DOM element.
2178
+ * @param barEl - The bar DOM element (for width measurement).
2179
+ * @param task - The {@link TaskNode} for this bar.
2180
+ * @param _getMapper - A function returning the current {@link PixelMapper} (unused, kept for API symmetry).
2181
+ * @param cbs - The chart callbacks.
2182
+ * @returns A cleanup function that removes all listeners.
2183
+ */
2184
+ function attachProgressDrag(progressEl, barEl, task, _getMapper, cbs) {
2185
+ function onProgressDown(e) {
2186
+ if (e.button !== 0) return;
2187
+ e.preventDefault();
2188
+ e.stopPropagation();
2189
+ cbs.onTaskSelect?.(task.id);
2190
+ try {
2191
+ progressEl.setPointerCapture(e.pointerId);
2192
+ } catch {}
2193
+ const startX = e.clientX;
2194
+ const barWidth = barEl.getBoundingClientRect().width;
2195
+ const origPercent = task.kind !== "milestone" ? task.percentComplete ?? 0 : 0;
2196
+ let lastPercent = origPercent;
2197
+ function onMove(me) {
2198
+ const dx = me.clientX - startX;
2199
+ const percentDelta = barWidth > 0 ? dx / barWidth * 100 : 0;
2200
+ lastPercent = Math.max(0, Math.min(100, Math.round(origPercent + percentDelta)));
2201
+ cbs.onTaskProgressDrag?.({
2202
+ id: task.id,
2203
+ percentComplete: lastPercent
2204
+ });
2205
+ }
2206
+ function onUp() {
2207
+ window.removeEventListener("pointermove", onMove);
2208
+ window.removeEventListener("pointerup", onUp);
2209
+ progressEl.style.cursor = "ew-resize";
2210
+ cbs._onTaskProgressDragFinal?.({
2211
+ id: task.id,
2212
+ percentComplete: lastPercent
2213
+ });
2214
+ }
2215
+ progressEl.style.cursor = "ew-resize";
2216
+ window.addEventListener("pointermove", onMove);
2217
+ window.addEventListener("pointerup", onUp);
2218
+ }
2219
+ progressEl.addEventListener("pointerdown", onProgressDown);
2220
+ return () => {
2221
+ progressEl.removeEventListener("pointerdown", onProgressDown);
2222
+ };
2223
+ }
2224
+ /**
1804
2225
  * Attaches click-to-select on a milestone diamond.
1805
2226
  *
1806
2227
  * @param diamondEl - The milestone diamond DOM element.
@@ -1810,16 +2231,14 @@ function attachDrag(barEl, resizeHandleEl, task, getMapper, cbs) {
1810
2231
  */
1811
2232
  function attachMilestoneClick(diamondEl, taskId, cbs) {
1812
2233
  function onClick() {
1813
- cbs.onSelect?.(taskId);
2234
+ cbs.onTaskSelect?.(taskId);
1814
2235
  }
1815
2236
  function onDoubleClick(event) {
1816
2237
  if (event.detail === 2) {
1817
2238
  const task = diamondEl.__task;
1818
2239
  if (task === void 0) return;
1819
- cbs.onTaskEditIntent?.({
2240
+ cbs.onTaskDoubleClick?.({
1820
2241
  id: taskId,
1821
- source: "milestone",
1822
- trigger: "doubleClick",
1823
2242
  task
1824
2243
  });
1825
2244
  }
@@ -1835,7 +2254,7 @@ function bindMilestoneTask(diamondEl, task) {
1835
2254
  diamondEl.__task = task;
1836
2255
  }
1837
2256
  //#endregion
1838
- //#region src/gantt-chart/vanilla/interaction/linkCreation.ts
2257
+ //#region src/lib/vanilla/interaction/linkCreation.ts
1839
2258
  /**
1840
2259
  * Attaches a link-creation drag listener to an endpoint handle.
1841
2260
  *
@@ -1917,7 +2336,7 @@ function createEndpointHandle() {
1917
2336
  return handle;
1918
2337
  }
1919
2338
  //#endregion
1920
- //#region src/gantt-chart/vanilla/dom/rightPane.ts
2339
+ //#region src/lib/vanilla/dom/rightPane.ts
1921
2340
  const BAR_COLOR = {
1922
2341
  task: "var(--gantt-task)",
1923
2342
  project: "var(--gantt-project)",
@@ -1993,7 +2412,7 @@ function renderSpecialDayBackgrounds(layer, beforeNode, state, contentHeight) {
1993
2412
  }
1994
2413
  function renderBar(layer, svgLayer, task, layout, selectedId, registry, state, cbs) {
1995
2414
  const selected = task.id === selectedId;
1996
- const color = BAR_COLOR[layout.type] ?? BAR_COLOR["task"];
2415
+ const color = BAR_COLOR[layout.kind] ?? BAR_COLOR["task"];
1997
2416
  const bar = el("div");
1998
2417
  bar.className = `gantt-bar${selected ? " gantt-bar--selected gantt-shape--selected" : ""}`;
1999
2418
  css(bar, {
@@ -2003,15 +2422,17 @@ function renderBar(layer, svgLayer, task, layout, selectedId, registry, state, c
2003
2422
  width: `${layout.width}px`,
2004
2423
  height: `${layout.height}px`,
2005
2424
  ...color === void 0 ? {} : { background: color },
2006
- borderRadius: layout.type === "project" ? "3px" : "4px",
2425
+ borderRadius: layout.kind === "project" ? "3px" : "4px",
2007
2426
  cursor: "grab",
2008
2427
  userSelect: "none",
2009
2428
  overflow: "hidden",
2010
2429
  zIndex: selected ? "3" : "2",
2011
2430
  touchAction: "none"
2012
2431
  });
2432
+ let cleanupProgressDrag;
2013
2433
  if (layout.progressWidth > 0) {
2014
2434
  const prog = el("div");
2435
+ const progressEnabled = state.progressDragEnabled;
2015
2436
  css(prog, {
2016
2437
  position: "absolute",
2017
2438
  left: "0",
@@ -2019,8 +2440,15 @@ function renderBar(layer, svgLayer, task, layout, selectedId, registry, state, c
2019
2440
  width: `${layout.progressWidth}px`,
2020
2441
  height: "100%",
2021
2442
  background: "rgba(0,0,0,0.18)",
2022
- pointerEvents: "none"
2443
+ ...progressEnabled ? {
2444
+ cursor: "ew-resize",
2445
+ touchAction: "none"
2446
+ } : { pointerEvents: "none" }
2023
2447
  });
2448
+ if (progressEnabled) {
2449
+ prog.className = "gantt-progress-overlay";
2450
+ cleanupProgressDrag = attachProgressDrag(prog, bar, task, () => state.mapper, cbs);
2451
+ }
2024
2452
  bar.append(prog);
2025
2453
  }
2026
2454
  const label = el("span");
@@ -2047,12 +2475,12 @@ function renderBar(layer, svgLayer, task, layout, selectedId, registry, state, c
2047
2475
  bar.setAttribute("aria-pressed", String(selected));
2048
2476
  bar.dataset["taskId"] = String(task.id);
2049
2477
  bar.addEventListener("click", () => {
2050
- cbs.onSelect?.(task.id);
2478
+ cbs.onTaskSelect?.(task.id);
2051
2479
  });
2052
2480
  bar.addEventListener("keydown", (event) => {
2053
2481
  if (event.key === "Enter" || event.key === " ") {
2054
2482
  event.preventDefault();
2055
- cbs.onSelect?.(task.id);
2483
+ cbs.onTaskSelect?.(task.id);
2056
2484
  }
2057
2485
  });
2058
2486
  const handle = el("div");
@@ -2110,6 +2538,7 @@ function renderBar(layer, svgLayer, task, layout, selectedId, registry, state, c
2110
2538
  cleanupDrag
2111
2539
  };
2112
2540
  if (cleanupLinkHandles !== void 0) entry.cleanupLinkHandles = cleanupLinkHandles;
2541
+ if (cleanupProgressDrag !== void 0) entry.cleanupProgressDrag = cleanupProgressDrag;
2113
2542
  registry.set(task.id, entry);
2114
2543
  }
2115
2544
  function renderMilestone(layer, svgLayer, task, layout, selectedId, registry, cbs, state) {
@@ -2136,7 +2565,7 @@ function renderMilestone(layer, svgLayer, task, layout, selectedId, registry, cb
2136
2565
  diamond.addEventListener("keydown", (event) => {
2137
2566
  if (event.key === "Enter" || event.key === " ") {
2138
2567
  event.preventDefault();
2139
- cbs.onSelect?.(task.id);
2568
+ cbs.onTaskSelect?.(task.id);
2140
2569
  }
2141
2570
  });
2142
2571
  const labelEl = el("span");
@@ -2235,9 +2664,10 @@ function renderRightPane(refs, state, cbs) {
2235
2664
  for (const child of [...absoluteLayer.children]) if (child !== svgLayer) toRemove.push(child);
2236
2665
  for (const node of toRemove) absoluteLayer.removeChild(node);
2237
2666
  hideGhostLine(svgLayer);
2238
- for (const { cleanupDrag, cleanupLinkHandles } of barRegistry.values()) {
2667
+ for (const { cleanupDrag, cleanupLinkHandles, cleanupProgressDrag } of barRegistry.values()) {
2239
2668
  cleanupDrag();
2240
2669
  cleanupLinkHandles?.();
2670
+ cleanupProgressDrag?.();
2241
2671
  }
2242
2672
  barRegistry.clear();
2243
2673
  if (scale === "day") renderSpecialDayBackgrounds(absoluteLayer, svgLayer, state, contentHeight);
@@ -2278,13 +2708,13 @@ function renderRightPane(refs, state, cbs) {
2278
2708
  for (const task of visibleRows) {
2279
2709
  const layout = layouts.get(task.id);
2280
2710
  if (layout === void 0) continue;
2281
- if (layout.type === "milestone") renderMilestone(absoluteLayer, svgLayer, task, layout, selectedId, barRegistry, cbs, state);
2711
+ if (layout.kind === "milestone") renderMilestone(absoluteLayer, svgLayer, task, layout, selectedId, barRegistry, cbs, state);
2282
2712
  else renderBar(absoluteLayer, svgLayer, task, layout, selectedId, barRegistry, state, cbs);
2283
2713
  }
2284
- updateDependencyLayer(svgLayer, links.filter((link) => visibleTaskIds.has(link.sourceTaskId) && visibleTaskIds.has(link.targetTaskId)), totalWidth, contentHeight, selectedId, highlightLinkedDependenciesOnSelect);
2714
+ updateDependencyLayer(svgLayer, links.filter((link) => visibleTaskIds.has(link.sourceTaskId) && visibleTaskIds.has(link.targetTaskId)), totalWidth, contentHeight, selectedId, highlightLinkedDependenciesOnSelect, cbs);
2285
2715
  }
2286
2716
  //#endregion
2287
- //#region src/gantt-chart/vanilla/utils.ts
2717
+ //#region src/lib/vanilla/utils.ts
2288
2718
  function buildTaskIndex(tasks) {
2289
2719
  const index = /* @__PURE__ */ new Map();
2290
2720
  for (let i = 0; i < tasks.length; i++) {
@@ -2333,11 +2763,11 @@ function getExpandableTaskIds(tasks) {
2333
2763
  function getInitialExpandedIds(tasks) {
2334
2764
  const expandableIds = getExpandableTaskIds(tasks);
2335
2765
  const expandedIds = /* @__PURE__ */ new Set();
2336
- for (const task of tasks) if (task.open && expandableIds.has(task.id)) expandedIds.add(task.id);
2766
+ for (const task of tasks) if (task.kind === "project" && task.open && expandableIds.has(task.id)) expandedIds.add(task.id);
2337
2767
  return expandedIds;
2338
2768
  }
2339
2769
  //#endregion
2340
- //#region src/gantt-chart/vanilla/splitter.ts
2770
+ //#region src/lib/vanilla/splitter.ts
2341
2771
  const MIN_PANE_WIDTH$1 = 96;
2342
2772
  function attachSplitter(splitterHandle, leftPane, container, timelineMinWidth, onDragEnd) {
2343
2773
  splitterHandle.addEventListener("pointerdown", (e) => {
@@ -2386,7 +2816,7 @@ function computeLeftPaneWidth(options) {
2386
2816
  return Math.max(MIN_PANE_WIDTH, Math.floor(width));
2387
2817
  }
2388
2818
  //#endregion
2389
- //#region src/gantt-chart/vanilla/gantt-chart.ts
2819
+ //#region src/lib/vanilla/gantt-chart.ts
2390
2820
  const HEADER_H = 52;
2391
2821
  const OVERSCAN = 4;
2392
2822
  /**
@@ -2405,6 +2835,7 @@ const OVERSCAN = 4;
2405
2835
  var GanttChart = class {
2406
2836
  #container;
2407
2837
  #opts;
2838
+ #callbacks;
2408
2839
  #input = null;
2409
2840
  #scale;
2410
2841
  #selectedId = null;
@@ -2412,6 +2843,7 @@ var GanttChart = class {
2412
2843
  #rafPending = false;
2413
2844
  #rafId = null;
2414
2845
  #destroyed = false;
2846
+ #dragOriginals = /* @__PURE__ */ new Map();
2415
2847
  #taskIndex;
2416
2848
  #lastGridClick = null;
2417
2849
  #userSplitWidth = null;
@@ -2426,6 +2858,7 @@ var GanttChart = class {
2426
2858
  #root;
2427
2859
  #scrollEl;
2428
2860
  #leftPane;
2861
+ #leftHeader;
2429
2862
  #leftBody;
2430
2863
  #rightPane;
2431
2864
  #rightHeader;
@@ -2440,10 +2873,11 @@ var GanttChart = class {
2440
2873
  * @param container - The host `HTMLElement` the chart will be appended to.
2441
2874
  * @param opts - Configuration and callback options.
2442
2875
  */
2443
- constructor(container, opts = {}) {
2876
+ constructor(container, opts = {}, cbs = {}) {
2444
2877
  this.#container = container;
2445
2878
  this.#scale = opts.scale ?? "day";
2446
2879
  this.#opts = opts;
2880
+ this.#callbacks = cbs;
2447
2881
  this.#taskIndex = /* @__PURE__ */ new Map();
2448
2882
  this.#locale = resolveChartLocale(opts.locale);
2449
2883
  this.#columns = opts.gridColumns ?? gridColumnDefaults(this.#locale);
@@ -2454,41 +2888,115 @@ var GanttChart = class {
2454
2888
  this.#specialDaysByDate = buildSpecialDayIndex(opts.specialDays ?? []);
2455
2889
  this.#expandedIds = /* @__PURE__ */ new Set();
2456
2890
  this.#cbs = {
2457
- onSelect: (id) => {
2891
+ onTaskSelect: (id) => {
2458
2892
  if (this.#selectedId === id) return;
2459
2893
  this.#selectedId = id;
2460
- opts.onSelect?.(this.#selectedId);
2894
+ if (this.#selectedId !== null) {
2895
+ const task = this.#findTask(this.#selectedId);
2896
+ if (task !== void 0) this.#callbacks.onTaskSelect?.({ task });
2897
+ }
2461
2898
  this.#scheduleRender();
2462
2899
  },
2463
2900
  onTaskDoubleClick: (payload) => {
2464
- opts.onTaskDoubleClick?.(payload);
2901
+ this.#callbacks.onTaskDoubleClick?.({ task: payload.task });
2465
2902
  },
2466
2903
  onTaskEditIntent: (payload) => {
2467
- opts.onTaskEditIntent?.(payload);
2468
- opts.onTaskDoubleClick?.({
2469
- id: payload.id,
2470
- source: payload.source
2471
- });
2904
+ this.#callbacks.onTaskDoubleClick?.({ task: payload.task });
2472
2905
  },
2473
- onMove: (payload) => {
2906
+ onTaskMove: (payload) => {
2907
+ if (!this.#dragOriginals.has(payload.id)) {
2908
+ const task = this.#input?.tasks.find((t) => t.id === payload.id);
2909
+ if (task !== void 0) this.#dragOriginals.set(payload.id, task);
2910
+ }
2474
2911
  const iso = payload.startDate.toISOString().slice(0, 10);
2475
2912
  this.#patchTask(payload.id, { startDate: iso });
2476
- opts.onMove?.(payload);
2477
2913
  this.#scheduleRender();
2478
2914
  },
2479
- onResize: (payload) => {
2915
+ _onTaskMoveFinal: (payload) => {
2916
+ const task = this.#findTask(payload.id);
2917
+ if (task !== void 0) {
2918
+ if (this.#callbacks.onTaskMove?.({
2919
+ task,
2920
+ newStartDate: payload.startDate
2921
+ }) === false) {
2922
+ const original = this.#dragOriginals.get(payload.id);
2923
+ if (original !== void 0) this.#patchTask(payload.id, { startDate: original.startDate });
2924
+ }
2925
+ }
2926
+ this.#dragOriginals.clear();
2927
+ this.#scheduleRender();
2928
+ return true;
2929
+ },
2930
+ onTaskResize: (payload) => {
2931
+ if (!this.#dragOriginals.has(payload.id)) {
2932
+ const task = this.#input?.tasks.find((t) => t.id === payload.id);
2933
+ if (task !== void 0) this.#dragOriginals.set(payload.id, task);
2934
+ }
2480
2935
  this.#patchTask(payload.id, { durationHours: payload.durationHours });
2481
- opts.onResize?.(payload);
2482
2936
  this.#scheduleRender();
2483
2937
  },
2938
+ _onTaskResizeFinal: (payload) => {
2939
+ const task = this.#findTask(payload.id);
2940
+ if (task !== void 0) {
2941
+ if (this.#callbacks.onTaskResize?.({
2942
+ task,
2943
+ newDurationHours: payload.durationHours
2944
+ }) === false) {
2945
+ const original = this.#dragOriginals.get(payload.id);
2946
+ if (original !== void 0 && original.kind !== "milestone") this.#patchTask(payload.id, { durationHours: original.durationHours });
2947
+ }
2948
+ }
2949
+ this.#dragOriginals.clear();
2950
+ this.#scheduleRender();
2951
+ return true;
2952
+ },
2953
+ onTaskProgressDrag: (payload) => {
2954
+ if (!this.#dragOriginals.has(payload.id)) {
2955
+ const task = this.#input?.tasks.find((t) => t.id === payload.id);
2956
+ if (task !== void 0) this.#dragOriginals.set(payload.id, task);
2957
+ }
2958
+ this.#patchTask(payload.id, { percentComplete: payload.percentComplete });
2959
+ this.#scheduleRender();
2960
+ },
2961
+ _onTaskProgressDragFinal: (payload) => {
2962
+ const task = this.#findTask(payload.id);
2963
+ if (task !== void 0) {
2964
+ if (this.#callbacks.onProgressChange?.({
2965
+ task,
2966
+ newPercentComplete: payload.percentComplete
2967
+ }) === false) {
2968
+ const original = this.#dragOriginals.get(payload.id);
2969
+ if (original !== void 0 && original.kind !== "milestone") this.#patchTask(payload.id, { percentComplete: original.percentComplete });
2970
+ }
2971
+ }
2972
+ this.#dragOriginals.clear();
2973
+ this.#scheduleRender();
2974
+ return true;
2975
+ },
2976
+ onTaskAdd: (parentId) => {
2977
+ const parentTask = this.#findTask(parentId);
2978
+ if (parentTask !== void 0) this.#callbacks.onTaskAdd?.({ parentTask });
2979
+ },
2484
2980
  onLeftPaneWidthChange: (width) => {
2485
- opts.onLeftPaneWidthChange?.(width);
2981
+ this.#callbacks.onLeftPaneWidthChange?.(width);
2486
2982
  },
2487
2983
  onGridColumnsChange: (updatedColumns) => {
2488
- opts.onGridColumnsChange?.(updatedColumns);
2984
+ this.#callbacks.onGridColumnsChange?.(updatedColumns);
2489
2985
  },
2490
2986
  onLinkCreate: (payload) => {
2491
- opts.onLinkCreate?.(payload);
2987
+ const sourceTask = this.#findTask(payload.sourceTaskId);
2988
+ const targetTask = this.#findTask(payload.targetTaskId);
2989
+ if (sourceTask !== void 0 && targetTask !== void 0) this.#callbacks.onLinkCreate?.({
2990
+ type: "FS",
2991
+ sourceTask,
2992
+ targetTask
2993
+ });
2994
+ },
2995
+ onLinkClick: (payload) => {
2996
+ this.#callbacks.onLinkClick?.({ link: payload });
2997
+ },
2998
+ onLinkDblClick: (payload) => {
2999
+ this.#callbacks.onLinkDblClick?.({ link: payload });
2492
3000
  }
2493
3001
  };
2494
3002
  this.#buildDom();
@@ -2508,9 +3016,9 @@ var GanttChart = class {
2508
3016
  this.#assertAlive();
2509
3017
  validateLinkRefs(newInput.tasks, newInput.links);
2510
3018
  detectCycles(newInput.tasks, newInput.links);
2511
- this.#input = newInput;
2512
- this.#taskIndex = buildTaskIndex(newInput.tasks);
2513
- this.#expandedIds = getInitialExpandedIds(newInput.tasks);
3019
+ this.#input = structuredClone(newInput);
3020
+ this.#taskIndex = buildTaskIndex(this.#input.tasks);
3021
+ this.#expandedIds = getInitialExpandedIds(this.#input.tasks);
2514
3022
  if (this.#rafPending && this.#rafId !== null) {
2515
3023
  cancelAnimationFrame(this.#rafId);
2516
3024
  this.#rafId = null;
@@ -2519,15 +3027,59 @@ var GanttChart = class {
2519
3027
  this.#render();
2520
3028
  }
2521
3029
  /**
2522
- * Switches the time scale and re-renders.
3030
+ * Merges the supplied options into the current configuration and re-renders
3031
+ * only the panes affected by the changed options.
2523
3032
  *
2524
- * @param scale - The new {@link TimeScale} to display.
3033
+ * @param opts - A partial {@link GanttOptions} object. Only the keys present
3034
+ * in this parameter are updated; missing keys keep their
3035
+ * previous values.
2525
3036
  * @throws {GanttError} When the instance has been destroyed.
2526
3037
  */
2527
- setScale(scale) {
3038
+ setOptions(opts) {
2528
3039
  this.#assertAlive();
2529
- this.#scale = scale;
2530
- this.#scheduleRender();
3040
+ Object.assign(this.#opts, opts);
3041
+ this.#scale = this.#opts.scale ?? "day";
3042
+ let columnsChanged = false;
3043
+ if (opts.locale !== void 0) {
3044
+ this.#locale = resolveChartLocale(opts.locale);
3045
+ if (this.#opts.gridColumns === void 0) {
3046
+ this.#columns = gridColumnDefaults(this.#locale);
3047
+ this.#leftPaneDefaultWidth = gridNaturalWidth(this.#columns);
3048
+ columnsChanged = true;
3049
+ }
3050
+ if (this.#opts.weekendDays === void 0) this.#weekendDays = normalizeWeekendDays(this.#locale.weekendDays);
3051
+ }
3052
+ if (opts.gridColumns !== void 0) {
3053
+ this.#columns = opts.gridColumns;
3054
+ this.#leftPaneDefaultWidth = this.#opts.leftPaneWidth ?? gridNaturalWidth(this.#columns);
3055
+ columnsChanged = true;
3056
+ }
3057
+ if (columnsChanged && this.#input !== null) this.#rebuildLeftPaneHeader();
3058
+ if (opts.leftPaneWidth !== void 0) this.#leftPaneDefaultWidth = opts.leftPaneWidth;
3059
+ if (opts.height !== void 0) {
3060
+ this.#height = opts.height;
3061
+ this.#root.style.height = `${this.#height}px`;
3062
+ }
3063
+ if (opts.timelineMinWidth !== void 0) {
3064
+ this.#timelineMinWidth = opts.timelineMinWidth;
3065
+ this.#rightPane.style.minWidth = `${this.#timelineMinWidth}px`;
3066
+ }
3067
+ if (opts.weekendDays !== void 0) this.#weekendDays = normalizeWeekendDays(opts.weekendDays);
3068
+ if (opts.specialDays !== void 0) this.#specialDaysByDate = buildSpecialDayIndex(opts.specialDays);
3069
+ if (opts.theme !== void 0) this.#applyTheme();
3070
+ 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;
3071
+ if (hasLayoutChange) this.#applyResponsivePaneStyles();
3072
+ const hasLeftPaneChange = columnsChanged || opts.locale !== void 0;
3073
+ 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;
3074
+ if (!(hasLeftPaneChange || hasRightPaneChange || hasLayoutChange)) return;
3075
+ if (this.#rafPending && this.#rafId !== null) {
3076
+ cancelAnimationFrame(this.#rafId);
3077
+ this.#rafId = null;
3078
+ this.#rafPending = false;
3079
+ }
3080
+ if (hasLeftPaneChange && !hasRightPaneChange) this.#renderGrid();
3081
+ else if (!hasLeftPaneChange && hasRightPaneChange) this.#renderTimeline();
3082
+ else this.#render();
2531
3083
  }
2532
3084
  /**
2533
3085
  * Programmatically selects or deselects a task.
@@ -2537,8 +3089,12 @@ var GanttChart = class {
2537
3089
  */
2538
3090
  select(id) {
2539
3091
  this.#assertAlive();
2540
- this.#selectedId = id;
2541
- this.#opts.onSelect?.(id);
3092
+ if (id === null) this.#selectedId = null;
3093
+ else {
3094
+ const task = this.#input?.tasks.find((t) => t.id === id);
3095
+ if (task !== void 0) this.#callbacks.onTaskSelect?.({ task });
3096
+ this.#selectedId = id;
3097
+ }
2542
3098
  if (this.#rafPending && this.#rafId !== null) {
2543
3099
  cancelAnimationFrame(this.#rafId);
2544
3100
  this.#rafId = null;
@@ -2589,9 +3145,10 @@ var GanttChart = class {
2589
3145
  else window.removeEventListener("resize", this.#applyResponsivePaneStyles);
2590
3146
  if (this.#rafId !== null) cancelAnimationFrame(this.#rafId);
2591
3147
  this.#columnResizeCleanup();
2592
- for (const { cleanupDrag, cleanupLinkHandles } of this.#rightPaneRefs.barRegistry.values()) {
3148
+ for (const { cleanupDrag, cleanupLinkHandles, cleanupProgressDrag } of this.#rightPaneRefs.barRegistry.values()) {
2593
3149
  cleanupDrag();
2594
3150
  cleanupLinkHandles?.();
3151
+ cleanupProgressDrag?.();
2595
3152
  }
2596
3153
  clearChildren(this.#container);
2597
3154
  }
@@ -2606,15 +3163,16 @@ var GanttChart = class {
2606
3163
  ...patch
2607
3164
  };
2608
3165
  }
3166
+ #findTask(id) {
3167
+ return this.#input?.tasks.find((t) => t.id === id);
3168
+ }
2609
3169
  #handleGridClick = (payload) => {
2610
3170
  const now = Date.now();
2611
3171
  const prev = this.#lastGridClick;
2612
3172
  if (prev !== null && prev.id === payload.id && now - prev.atMs <= 350) {
2613
3173
  this.#lastGridClick = null;
2614
- this.#cbs.onTaskEditIntent?.({
3174
+ this.#cbs.onTaskDoubleClick?.({
2615
3175
  id: payload.id,
2616
- source: "grid",
2617
- trigger: "doubleClick",
2618
3176
  task: payload.task
2619
3177
  });
2620
3178
  return;
@@ -2623,7 +3181,7 @@ var GanttChart = class {
2623
3181
  id: payload.id,
2624
3182
  atMs: now
2625
3183
  };
2626
- this.#cbs.onSelect?.(payload.id);
3184
+ this.#cbs.onTaskSelect?.(payload.id);
2627
3185
  };
2628
3186
  #onScroll = () => {
2629
3187
  ({scrollTop: this.#scrollTop} = this.#scrollEl);
@@ -2664,6 +3222,7 @@ var GanttChart = class {
2664
3222
  scale: this.#scale,
2665
3223
  highlightLinkedDependenciesOnSelect: this.#opts.highlightLinkedDependenciesOnSelect ?? false,
2666
3224
  linkCreationEnabled: this.#opts.linkCreationEnabled ?? false,
3225
+ progressDragEnabled: this.#opts.progressDragEnabled ?? false,
2667
3226
  expandedIds: this.#expandedIds,
2668
3227
  selectedId: this.#selectedId,
2669
3228
  scrollTop: this.#scrollTop,
@@ -2690,21 +3249,47 @@ var GanttChart = class {
2690
3249
  if (input === null) return;
2691
3250
  const state = this.#computeState(input);
2692
3251
  renderTimeHeader(this.#rightHeader, state);
3252
+ this.#renderGridInternal(state);
3253
+ renderRightPane(this.#rightPaneRefs, state, this.#cbs);
3254
+ };
3255
+ #renderGrid = () => {
3256
+ this.#rafPending = false;
3257
+ const input = this.#input;
3258
+ if (input === null) return;
3259
+ this.#renderGridInternal(this.#computeState(input));
3260
+ };
3261
+ #renderGridInternal(state) {
2693
3262
  renderLeftPane(this.#leftBody, state, {
2694
3263
  onToggle: (id) => {
2695
3264
  if (this.#expandedIds.has(id)) this.#expandedIds.delete(id);
2696
3265
  else this.#expandedIds.add(id);
2697
3266
  this.#scheduleRender();
2698
3267
  },
2699
- onSelect: (id) => this.#cbs.onSelect?.(id),
3268
+ onTaskSelect: (id) => this.#cbs.onTaskSelect?.(id),
2700
3269
  onRowClick: (payload) => {
2701
3270
  this.#handleGridClick(payload);
2702
3271
  },
2703
- onTaskEditIntent: (payload) => this.#cbs.onTaskEditIntent?.(payload),
2704
- onAdd: (id) => this.#cbs.onAdd?.({ parentId: id })
3272
+ onTaskDoubleClick: (payload) => this.#cbs.onTaskDoubleClick?.(payload),
3273
+ onTaskAdd: (id) => this.#cbs.onTaskAdd?.(id)
2705
3274
  }, this.#columns);
3275
+ }
3276
+ #renderTimeline = () => {
3277
+ this.#rafPending = false;
3278
+ const input = this.#input;
3279
+ if (input === null) return;
3280
+ const state = this.#computeState(input);
3281
+ renderTimeHeader(this.#rightHeader, state);
2706
3282
  renderRightPane(this.#rightPaneRefs, state, this.#cbs);
2707
3283
  };
3284
+ #rebuildLeftPaneHeader() {
3285
+ this.#columnResizeCleanup();
3286
+ clearChildren(this.#leftHeader);
3287
+ const headerEl = buildLeftPaneHeader(this.#columns);
3288
+ this.#leftHeader.append(headerEl);
3289
+ this.#columnResizeCleanup = setupColumnResize(headerEl, this.#leftBody, this.#columns, (updated) => {
3290
+ this.#cbs.onGridColumnsChange?.(updated);
3291
+ });
3292
+ }
2708
3293
  #scheduleRender() {
2709
3294
  if (this.#rafPending || this.#destroyed) return;
2710
3295
  this.#rafPending = true;
@@ -2760,6 +3345,7 @@ var GanttChart = class {
2760
3345
  const headerEl = buildLeftPaneHeader(this.#columns);
2761
3346
  leftHeader.append(headerEl);
2762
3347
  leftPane.append(leftHeader);
3348
+ this.#leftHeader = leftHeader;
2763
3349
  const leftBody = el("div");
2764
3350
  leftPane.append(leftBody);
2765
3351
  this.#leftBody = leftBody;
@@ -2807,12 +3393,14 @@ var GanttChart = class {
2807
3393
  #wireEvents() {
2808
3394
  this.#rightPaneRefs.absoluteLayer.addEventListener("click", (event) => {
2809
3395
  if (event.target.closest(".gantt-bar, .gantt-milestone, .gantt-resize-handle")) return;
2810
- this.#cbs.onSelect?.(null);
3396
+ this.#selectedId = null;
3397
+ this.#scheduleRender();
2811
3398
  });
2812
3399
  this.#root.addEventListener("keydown", (event) => {
2813
3400
  if (event.key === "Escape" && this.#selectedId !== null) {
2814
3401
  event.preventDefault();
2815
- this.#cbs.onSelect?.(null);
3402
+ this.#selectedId = null;
3403
+ this.#scheduleRender();
2816
3404
  }
2817
3405
  });
2818
3406
  this.#scrollEl.addEventListener("scroll", this.#onScroll);
@@ -2827,6 +3415,6 @@ var GanttChart = class {
2827
3415
  }
2828
3416
  };
2829
3417
  //#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 };
3418
+ 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
3419
 
2832
3420
  //# sourceMappingURL=index.mjs.map