gantt-renderer 0.1.2 → 0.2.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
@@ -20,43 +20,95 @@ const SpecialDaySchema = z.object({
20
20
  className: z.string().min(1).optional()
21
21
  });
22
22
  const TaskSchema = z.object({
23
+ /** Unique positive integer identifier for the task. */
23
24
  id: z.number().int().positive(),
25
+ /** Display name / label of the task. */
24
26
  text: z.string().min(1),
25
27
  /** ISO date: YYYY-MM-DD */
26
- start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD"),
27
- /** Duration in days; 0 = milestone */
28
- duration: z.number().int().min(0),
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),
31
+ /** Optional id of the parent task. When set, this task is a child in the hierarchy. */
29
32
  parent: z.number().int().positive().optional(),
30
- /** 0–1 completion ratio */
31
- progress: z.number().min(0).max(1).default(0),
33
+ /** 0–100 completion percentage (integer) */
34
+ percentComplete: z.number().int().min(0).max(100).default(0),
35
+ /**
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
+ */
32
44
  type: TaskTypeSchema.default("task"),
33
- /** Initial expanded state (only relevant for parent nodes) */
45
+ /**
46
+ * Initial expanded state for tree hierarchy.
47
+ * When `false`, children of this task are hidden on initial render.
48
+ * Only relevant for tasks with child tasks.
49
+ *
50
+ * @default true
51
+ */
34
52
  open: z.boolean().default(true),
53
+ /** Optional CSS color value for the task bar. Overrides the default color assignment. */
35
54
  color: z.string().optional()
36
55
  });
37
56
  const LinkSchema = z.object({
57
+ /** Unique positive integer identifier for the dependency link. */
38
58
  id: z.number().int().positive(),
59
+ /** The `id` of the predecessor task (the task that drives the dependency). */
39
60
  source: z.number().int().positive(),
61
+ /** The `id` of the successor task (the task that depends on the predecessor). */
40
62
  target: z.number().int().positive(),
63
+ /**
64
+ * Dependency type.
65
+ *
66
+ * - `'FS'` — Finish-to-start: successor starts after predecessor finishes.
67
+ * - `'SS'` — Start-to-start: successor starts at the same time as predecessor.
68
+ * - `'FF'` — Finish-to-finish: successor finishes at the same time as predecessor.
69
+ * - `'SF'` — Start-to-finish: successor finishes after predecessor starts.
70
+ *
71
+ * @default 'FS'
72
+ */
41
73
  type: LinkTypeSchema.default("FS")
42
74
  });
43
75
  const GanttInputSchema = z.object({
76
+ /** Array of task objects. At least one task is required. */
44
77
  tasks: z.array(TaskSchema).min(1),
78
+ /** Optional array of dependency link objects. Defaults to empty array. */
45
79
  links: z.array(LinkSchema).default([])
46
80
  });
47
- /** Parses raw external data. Throws ZodError on failure. */
81
+ /**
82
+ * Parses raw external data.
83
+ *
84
+ * @param raw - The unvalidated input from the consumer.
85
+ * @returns The parsed and validated {@link GanttInput}.
86
+ * @throws {import('zod').ZodError} On schema validation failure.
87
+ */
48
88
  function parseGanttInput(raw) {
49
89
  return GanttInputSchema.parse(raw);
50
90
  }
51
- /** Returns null instead of throwing. */
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
+ */
52
97
  function safeParseGanttInput(raw) {
53
98
  const result = GanttInputSchema.safeParse(raw);
54
99
  return result.success ? result.data : null;
55
100
  }
56
101
  //#endregion
57
102
  //#region src/gantt-chart/errors.ts
103
+ /**
104
+ * Domain-specific error with a machine-readable {@link GanttErrorCode}.
105
+ */
58
106
  var GanttError = class extends Error {
59
107
  code;
108
+ /**
109
+ * @param code - A machine-readable {@link GanttErrorCode} categorising the error.
110
+ * @param message - A human-readable description.
111
+ */
60
112
  constructor(code, message) {
61
113
  super(message);
62
114
  this.name = "GanttError";
@@ -68,7 +120,10 @@ var GanttError = class extends Error {
68
120
  /**
69
121
  * Builds a typed tree from a flat task array.
70
122
  * Order of tasks[] is irrelevant — parents need not precede children.
71
- * Throws if any parent reference is unresolvable.
123
+ *
124
+ * @param tasks - The flat array of tasks to convert into a tree.
125
+ * @returns Root-level {@link TaskNode} instances with populated `children`.
126
+ * @throws {GanttError} When a task references a `parent` id that does not exist.
72
127
  */
73
128
  function buildTaskTree(tasks) {
74
129
  const map = /* @__PURE__ */ new Map();
@@ -97,7 +152,11 @@ function buildTaskTree(tasks) {
97
152
  }
98
153
  /**
99
154
  * Flattens a tree into a visible row list.
100
- * A node's children are included only when its id is in expandedIds.
155
+ * A node's children are included only when its id is in `expandedIds`.
156
+ *
157
+ * @param roots - The root-level {@link TaskNode} instances of the tree.
158
+ * @param expandedIds - Set of task IDs whose children should be rendered.
159
+ * @returns A depth-first flattened array of visible {@link TaskNode} items.
101
160
  */
102
161
  function flattenTree(roots, expandedIds) {
103
162
  const rows = [];
@@ -108,7 +167,12 @@ function flattenTree(roots, expandedIds) {
108
167
  for (const root of roots) walk(root);
109
168
  return rows;
110
169
  }
111
- /** Returns true when a node has children in the tree. */
170
+ /**
171
+ * Returns `true` when a node has children in the tree.
172
+ *
173
+ * @param node - The {@link TaskNode} to inspect.
174
+ * @returns `true` if `node.children.length > 0`.
175
+ */
112
176
  function isParent(node) {
113
177
  return node.children.length > 0;
114
178
  }
@@ -116,8 +180,10 @@ function isParent(node) {
116
180
  //#region src/gantt-chart/domain/dependencies.ts
117
181
  /**
118
182
  * Detects circular dependencies in the link graph using DFS tri-colour marking.
119
- * Throws with a human-readable cycle path on detection.
120
- * Silent return = no cycles.
183
+ *
184
+ * @param tasks - The task list (used to build the vertex set).
185
+ * @param links - The dependency links defining the directed edges.
186
+ * @throws {GanttError} When a cycle is detected, with a human-readable cycle path.
121
187
  */
122
188
  function detectCycles(tasks, links) {
123
189
  const adj = /* @__PURE__ */ new Map();
@@ -155,8 +221,11 @@ function detectCycles(tasks, links) {
155
221
  for (const id of adj.keys()) if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
156
222
  }
157
223
  /**
158
- * Returns true when all link source/target ids reference existing tasks.
159
- * Throws with details on failure.
224
+ * Validates that every link references existing task IDs.
225
+ *
226
+ * @param tasks - The task list (used as the reference set of valid IDs).
227
+ * @param links - The dependency links to validate.
228
+ * @throws {GanttError} When any link references a non-existent source or target task.
160
229
  */
161
230
  function validateLinkRefs(tasks, links) {
162
231
  const ids = new Set(tasks.map((t) => t.id));
@@ -167,76 +236,6 @@ function validateLinkRefs(tasks, links) {
167
236
  }
168
237
  //#endregion
169
238
  //#region src/gantt-chart/locale.ts
170
- const EN_US_LABELS = {
171
- aria_task: "Task {0}",
172
- aria_milestone: "Milestone {0}",
173
- add_subtask_title: "Add subtask",
174
- column_task_name: "Task name",
175
- column_start_time: "Start time",
176
- column_duration: "Duration",
177
- column_quarter: "Q"
178
- };
179
- const CHART_LOCALE_EN_US = {
180
- code: "en-US",
181
- labels: EN_US_LABELS,
182
- weekStartsOn: 0,
183
- weekNumbering: "iso",
184
- weekendDays: [0, 6]
185
- };
186
- /**
187
- * Resolves a ChartLocale from either a full ChartLocale object or a BCP 47 string.
188
- * When given a string, derives weekStartsOn, weekNumbering, and weekendDays from CLDR conventions.
189
- */
190
- function resolveChartLocale(raw) {
191
- if (raw === void 0) return CHART_LOCALE_EN_US;
192
- if (typeof raw !== "string") {
193
- const locale = {
194
- code: raw.code,
195
- weekStartsOn: raw.weekStartsOn ?? deriveWeekStartsOn(raw.code),
196
- weekNumbering: raw.weekNumbering ?? deriveWeekNumbering(raw.code),
197
- weekendDays: raw.weekendDays ?? deriveWeekendDays(raw.code)
198
- };
199
- if (raw.labels !== void 0) locale.labels = raw.labels;
200
- return locale;
201
- }
202
- const code = raw;
203
- return {
204
- code,
205
- weekStartsOn: deriveWeekStartsOn(code),
206
- weekNumbering: deriveWeekNumbering(code),
207
- weekendDays: deriveWeekendDays(code)
208
- };
209
- }
210
- function tryGetWeekInfo(code) {
211
- try {
212
- if (typeof Intl !== "undefined" && typeof Intl.Locale === "function") {
213
- const locale = new Intl.Locale(code);
214
- const fn = locale.getWeekInfo;
215
- if (typeof fn === "function") return fn.call(locale);
216
- }
217
- } catch {}
218
- }
219
- /**
220
- * Derives the first day of week (0=Sun, 1=Mon, 6=Sat) from a BCP 47 code.
221
- * Uses Intl.Locale.getWeekInfo() where available (Chromium, Safari 15.4+),
222
- * with a CLDR-based fallback table for Firefox and older runtimes.
223
- */
224
- function deriveWeekStartsOn(code) {
225
- const primary = code.split("-")[0]?.toLowerCase() ?? "en";
226
- const region = code.split("-")[1]?.toUpperCase();
227
- if (region !== void 0) {
228
- const fromRegion = WEEK_START_REGION[region];
229
- if (fromRegion !== void 0) return fromRegion;
230
- }
231
- const fromLang = WEEK_START_LANG[primary];
232
- if (fromLang !== void 0) return fromLang;
233
- const info = tryGetWeekInfo(code);
234
- if (info !== void 0) {
235
- const day = info.firstDay;
236
- return day === 7 ? 0 : day;
237
- }
238
- return 1;
239
- }
240
239
  const WEEK_START_REGION = {
241
240
  US: 0,
242
241
  CA: 0,
@@ -289,24 +288,6 @@ const WEEK_START_LANG = {
289
288
  ar: 6,
290
289
  fa: 6
291
290
  };
292
- /**
293
- * Derives the week numbering scheme from a BCP 47 code.
294
- * Europe and ISO-aligned regions default to 'iso'; Americas and others to 'us'.
295
- */
296
- function deriveWeekNumbering(code) {
297
- const region = code.split("-")[1]?.toUpperCase();
298
- if (region !== void 0) {
299
- const fromRegion = WEEK_NUMBERING_REGION[region];
300
- if (fromRegion !== void 0) return fromRegion;
301
- if (region in WEEK_START_REGION) return "us";
302
- }
303
- const info = tryGetWeekInfo(code);
304
- if (info !== void 0) {
305
- if (info.minimalDays >= 4 && info.firstDay === 1) return "iso";
306
- return "us";
307
- }
308
- return "iso";
309
- }
310
291
  const WEEK_NUMBERING_REGION = {
311
292
  US: "us",
312
293
  CA: "us",
@@ -330,28 +311,6 @@ const WEEK_NUMBERING_REGION = {
330
311
  AU: "us",
331
312
  NZ: "us"
332
313
  };
333
- /**
334
- * Derives weekend days (0=Sun … 6=Sat) from a BCP 47 code.
335
- * Uses Intl.Locale.getWeekInfo() where available, with a CLDR-based fallback table.
336
- */
337
- function deriveWeekendDays(code) {
338
- const region = code.split("-")[1]?.toUpperCase();
339
- if (region !== void 0) {
340
- const fromRegion = WEEKEND_REGION[region];
341
- if (fromRegion !== void 0) {
342
- const days = [...fromRegion];
343
- days.sort((a, b) => a - b);
344
- return days;
345
- }
346
- }
347
- const info = tryGetWeekInfo(code);
348
- if (info !== void 0) {
349
- const days = info.weekend.map((d) => d === 7 ? 0 : d);
350
- days.sort((a, b) => a - b);
351
- return days;
352
- }
353
- return [0, 6];
354
- }
355
314
  const WEEKEND_REGION = {
356
315
  AE: [5, 6],
357
316
  AF: [4, 5],
@@ -385,19 +344,127 @@ const WEEKEND_REGION = {
385
344
  SO: [5],
386
345
  MY: [5, 0]
387
346
  };
347
+ const EN_US_LABELS = {
348
+ ariaTask: "Task {0}",
349
+ ariaMilestone: "Milestone {0}",
350
+ addSubtaskTitle: "Add subtask",
351
+ columnTaskName: "Task name",
352
+ columnStartDate: "Start time",
353
+ columnDuration: "Duration",
354
+ columnQuarter: "Q"
355
+ };
356
+ const CHART_LOCALE_EN_US = {
357
+ code: "en-US",
358
+ labels: EN_US_LABELS,
359
+ weekStartsOn: 0,
360
+ weekNumbering: "iso",
361
+ weekendDays: [0, 6]
362
+ };
363
+ function tryGetWeekInfo(code) {
364
+ try {
365
+ if (typeof Intl !== "undefined" && typeof Intl.Locale === "function") {
366
+ const locale = new Intl.Locale(code);
367
+ const fn = locale.getWeekInfo;
368
+ if (typeof fn === "function") return fn.call(locale);
369
+ }
370
+ } catch {}
371
+ }
388
372
  /**
389
- * Formats a week number according to the specified scheme.
373
+ * Derives the first day of week (0=Sun, 1=Mon, 6=Sat) from a BCP 47 code.
374
+ * Uses `Intl.Locale.getWeekInfo()` where available (Chromium, Safari 15.4+),
375
+ * with a CLDR-based fallback table for Firefox and older runtimes.
390
376
  *
391
- * - `'iso'`: ISO 8601 (week 1 contains the first Thursday; Monday start).
392
- * - `'us'`: Week 1 contains January 1; Sunday start.
393
- * - `'simple'`: `Math.ceil(dayOfYear / 7)`.
377
+ * @param code - A BCP 47 language tag (e.g. `'en-US'`, `'de-DE'`).
378
+ * @returns The first day of the week: `0` (Sunday), `1` (Monday), or `6` (Saturday).
394
379
  */
395
- function formatWeekNumber(date, scheme) {
396
- switch (scheme) {
397
- case "iso": return isoWeek(date);
398
- case "us": return usWeek(date);
399
- case "simple": return simpleWeek(date);
380
+ function deriveWeekStartsOn(code) {
381
+ const primary = code.split("-")[0]?.toLowerCase() ?? "en";
382
+ const region = code.split("-")[1]?.toUpperCase();
383
+ if (region !== void 0) {
384
+ const fromRegion = WEEK_START_REGION[region];
385
+ if (fromRegion !== void 0) return fromRegion;
386
+ }
387
+ const fromLang = WEEK_START_LANG[primary];
388
+ if (fromLang !== void 0) return fromLang;
389
+ const info = tryGetWeekInfo(code);
390
+ if (info !== void 0) {
391
+ const day = info.firstDay;
392
+ return day === 7 ? 0 : day;
393
+ }
394
+ return 1;
395
+ }
396
+ /**
397
+ * Derives the week numbering scheme from a BCP 47 code.
398
+ * Europe and ISO-aligned regions default to `'iso'`; Americas and others to `'us'`.
399
+ *
400
+ * @param code - A BCP 47 language tag (e.g. `'en-US'`, `'de-DE'`).
401
+ * @returns The week numbering scheme: `'iso'`, `'us'`, or `'simple'`.
402
+ */
403
+ function deriveWeekNumbering(code) {
404
+ const region = code.split("-")[1]?.toUpperCase();
405
+ if (region !== void 0) {
406
+ const fromRegion = WEEK_NUMBERING_REGION[region];
407
+ if (fromRegion !== void 0) return fromRegion;
408
+ if (region in WEEK_START_REGION) return "us";
409
+ }
410
+ const info = tryGetWeekInfo(code);
411
+ if (info !== void 0) {
412
+ if (info.minimalDays >= 4 && info.firstDay === 1) return "iso";
413
+ return "us";
414
+ }
415
+ return "iso";
416
+ }
417
+ /**
418
+ * Derives weekend days (0=Sun … 6=Sat) from a BCP 47 code.
419
+ * Uses `Intl.Locale.getWeekInfo()` where available, with a CLDR-based fallback table.
420
+ *
421
+ * @param code - A BCP 47 language tag (e.g. `'en-US'`, `'de-DE'`).
422
+ * @returns An array of weekend day indices (sorted ascending).
423
+ */
424
+ function deriveWeekendDays(code) {
425
+ const region = code.split("-")[1]?.toUpperCase();
426
+ if (region !== void 0) {
427
+ const fromRegion = WEEKEND_REGION[region];
428
+ if (fromRegion !== void 0) {
429
+ const days = [...fromRegion];
430
+ days.sort((a, b) => a - b);
431
+ return days;
432
+ }
433
+ }
434
+ const info = tryGetWeekInfo(code);
435
+ if (info !== void 0) {
436
+ const days = info.weekend.map((d) => d === 7 ? 0 : d);
437
+ days.sort((a, b) => a - b);
438
+ return days;
439
+ }
440
+ return [0, 6];
441
+ }
442
+ /**
443
+ * Resolves a {@link ChartLocale} from either a full `ChartLocale` object or a BCP 47 string.
444
+ * When given a string, derives `weekStartsOn`, `weekNumbering`, and `weekendDays` from CLDR conventions.
445
+ *
446
+ * @param raw - A {@link ChartLocale} object, a BCP 47 language tag string, or `undefined`.
447
+ * @returns A fully resolved {@link ChartLocale} with defaults applied.
448
+ */
449
+ function resolveChartLocale(raw) {
450
+ if (raw === void 0) return CHART_LOCALE_EN_US;
451
+ if (typeof raw !== "string") {
452
+ const locale = {
453
+ code: raw.code,
454
+ weekStartsOn: raw.weekStartsOn ?? deriveWeekStartsOn(raw.code),
455
+ weekNumbering: raw.weekNumbering ?? deriveWeekNumbering(raw.code),
456
+ weekendDays: raw.weekendDays ?? deriveWeekendDays(raw.code)
457
+ };
458
+ if (raw.labels !== void 0) locale.labels = raw.labels;
459
+ return locale;
400
460
  }
461
+ const code = raw;
462
+ return {
463
+ code,
464
+ weekStartsOn: deriveWeekStartsOn(code),
465
+ weekNumbering: deriveWeekNumbering(code),
466
+ weekendDays: deriveWeekendDays(code)
467
+ };
401
468
  }
402
469
  function isoWeek(date) {
403
470
  const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
@@ -422,33 +489,107 @@ function simpleWeek(date) {
422
489
  return Math.ceil((dayOfYear + 1) / 7);
423
490
  }
424
491
  /**
492
+ * Formats a week number according to the specified scheme.
493
+ *
494
+ * - `'iso'`: ISO 8601 (week 1 contains the first Thursday; Monday start).
495
+ * - `'us'`: Week 1 contains January 1; Sunday start.
496
+ * - `'simple'`: `Math.ceil(dayOfYear / 7)`.
497
+ *
498
+ * @param date - The date to compute the week number for.
499
+ * @param scheme - The week numbering scheme: `'iso'`, `'us'`, or `'simple'`.
500
+ * @returns The week number as a positive integer.
501
+ */
502
+ function formatWeekNumber(date, scheme) {
503
+ switch (scheme) {
504
+ case "iso": return isoWeek(date);
505
+ case "us": return usWeek(date);
506
+ case "simple": return simpleWeek(date);
507
+ }
508
+ }
509
+ /**
425
510
  * Formats a label template by replacing `{0}` with the given argument.
511
+ *
512
+ * @param template - The template string containing `{0}` as placeholder.
513
+ * @param arg - The value to substitute for `{0}`.
514
+ * @returns The formatted string with the placeholder replaced.
426
515
  */
427
516
  function formatLabel(template, arg) {
428
517
  return template.replaceAll("{0}", arg);
429
518
  }
430
519
  //#endregion
431
520
  //#region src/gantt-chart/domain/dateMath.ts
432
- /** Parses YYYY-MM-DD → UTC midnight Date. Throws on invalid input. */
521
+ /**
522
+ * Parses `YYYY-MM-DD` → UTC midnight `Date`.
523
+ *
524
+ * @param dateStr - An ISO-8601 date string in `YYYY-MM-DD` format.
525
+ * @returns A `Date` representing UTC midnight of the given date.
526
+ * @throws {Error} When `dateStr` does not represent a valid date.
527
+ */
433
528
  function parseDate(dateStr) {
434
529
  const d = /* @__PURE__ */ new Date(`${dateStr}T00:00:00.000Z`);
435
530
  if (isNaN(d.getTime())) throw new Error(`Invalid date: "${dateStr}"`);
436
531
  return d;
437
532
  }
438
- /** Returns date + n days (exact ms arithmetic). */
533
+ /**
534
+ * Returns `date + n` days using exact millisecond arithmetic.
535
+ *
536
+ * @param date - The base date.
537
+ * @param days - Number of days to add (may be negative).
538
+ * @returns A new `Date` offset by the given number of days.
539
+ */
439
540
  function addDays(date, days) {
440
541
  return new Date(date.getTime() + days * 864e5);
441
542
  }
442
- /** Difference in days (float). Positive when b > a. */
543
+ /**
544
+ * Returns `date + n` hours using exact millisecond arithmetic.
545
+ *
546
+ * @param date - The base date.
547
+ * @param hours - Number of hours to add (may be negative).
548
+ * @returns A new `Date` offset by the given number of hours.
549
+ */
550
+ function addHours(date, hours) {
551
+ return new Date(date.getTime() + hours * 36e5);
552
+ }
553
+ /**
554
+ * Difference in days (float). Positive when `b > a`.
555
+ *
556
+ * @param a - The earlier date.
557
+ * @param b - The later date.
558
+ * @returns The fractional number of days between the two dates.
559
+ */
443
560
  function diffDays(a, b) {
444
561
  return (b.getTime() - a.getTime()) / 864e5;
445
562
  }
446
- /** UTC start-of-day. */
563
+ /**
564
+ * Difference in hours (float). Positive when `b > a`.
565
+ *
566
+ * @param a - The earlier date.
567
+ * @param b - The later date.
568
+ * @returns The fractional number of hours between the two dates.
569
+ */
570
+ function diffHours(a, b) {
571
+ return (b.getTime() - a.getTime()) / 36e5;
572
+ }
573
+ /**
574
+ * Returns the UTC start-of-day for the given date.
575
+ *
576
+ * @param date - Any `Date`.
577
+ * @returns A new `Date` set to UTC midnight of the same calendar date.
578
+ */
447
579
  function startOfDay(date) {
448
580
  return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
449
581
  }
582
+ function resolveQuarterLabel(locale) {
583
+ if (locale.labels?.columnQuarter !== void 0) return locale.labels.columnQuarter;
584
+ return EN_US_LABELS.columnQuarter;
585
+ }
450
586
  /**
451
- * Formats a Date for the time-header label given the active scale.
587
+ * Formats a `Date` for the time-header label given the active scale.
588
+ *
589
+ * @param date - The date to format.
590
+ * @param scale - The active {@link TimeScale} determining the label granularity.
591
+ * @param locale - The {@link ChartLocale} used for formatting.
592
+ * @returns A human-readable header label string.
452
593
  */
453
594
  function formatHeaderLabel(date, scale, locale) {
454
595
  const { code, weekNumbering: weekNumScheme = "iso" } = locale;
@@ -473,7 +614,12 @@ function formatHeaderLabel(date, scale, locale) {
473
614
  }
474
615
  /**
475
616
  * Returns the upper-level (month/year) label for a given scale column.
476
- * Used in the top header row.
617
+ * Used in the top header row of the timeline.
618
+ *
619
+ * @param date - The date to format.
620
+ * @param scale - The active {@link TimeScale}. Determines how the upper label is computed.
621
+ * @param locale - The {@link ChartLocale} used for formatting.
622
+ * @returns A human-readable upper-level header label string.
477
623
  */
478
624
  function formatUpperLabel(date, scale, locale) {
479
625
  const { code } = locale;
@@ -495,11 +641,13 @@ function formatUpperLabel(date, scale, locale) {
495
641
  case "year": return `${date.getUTCFullYear()}`;
496
642
  }
497
643
  }
498
- function resolveQuarterLabel(locale) {
499
- if (locale.labels?.column_quarter !== void 0) return locale.labels.column_quarter;
500
- return EN_US_LABELS.column_quarter;
501
- }
502
- /** Formats YYYY-MM-DD for display in the grid. */
644
+ /**
645
+ * Formats a `YYYY-MM-DD` string for display in the grid.
646
+ *
647
+ * @param dateStr - An ISO-8601 date string in `YYYY-MM-DD` format.
648
+ * @param locale - The {@link ChartLocale} used for locale-aware formatting.
649
+ * @returns A locale-formatted date string.
650
+ */
503
651
  function formatDisplayDate(dateStr, locale) {
504
652
  return parseDate(dateStr).toLocaleDateString(locale.code, {
505
653
  year: "numeric",
@@ -547,8 +695,12 @@ const SCALE_CONFIGS = {
547
695
  /**
548
696
  * Snaps a date to the column boundary for the provided scale.
549
697
  * All operations use UTC semantics.
550
- * The week boundary respects the optional weekStartsOn override (0=Sun, 1=Mon, 6=Sat).
551
- * Defaults to Monday (1) when omitted.
698
+ * The week boundary respects the optional `weekStartsOn` override (0=Sun, 1=Mon, 6=Sat).
699
+ *
700
+ * @param date - The date to snap.
701
+ * @param scale - The target {@link TimeScale}.
702
+ * @param weekStartsOn - First day of the week (`0`-Sun, `1`-Mon, `6`-Sat). Defaults to `1` (Monday).
703
+ * @returns A new `Date` snapped to the column boundary.
552
704
  */
553
705
  function snapToScaleBoundary(date, scale, weekStartsOn = 1) {
554
706
  switch (scale) {
@@ -572,6 +724,10 @@ function snapToScaleBoundary(date, scale, weekStartsOn = 1) {
572
724
  /**
573
725
  * Returns the next column boundary from a boundary-aligned date.
574
726
  * Month/quarter/year use true calendar stepping (not fixed-day approximations).
727
+ *
728
+ * @param date - A boundary-aligned date.
729
+ * @param scale - The target {@link TimeScale}.
730
+ * @returns The next boundary date.
575
731
  */
576
732
  function nextScaleBoundary(date, scale) {
577
733
  switch (scale) {
@@ -588,13 +744,17 @@ function nextScaleBoundary(date, scale) {
588
744
  /**
589
745
  * Creates a stateless pixel mapper for the given scale and viewport start.
590
746
  * All conversions are O(1) arithmetic — safe to call in tight loops.
747
+ *
748
+ * @param scale - The active {@link TimeScale}.
749
+ * @param viewportStart - The leftmost date visible in the viewport.
750
+ * @returns A {@link PixelMapper} configured for the given viewport.
591
751
  */
592
752
  function createPixelMapper(scale, viewportStart) {
593
753
  const { columnWidth, msPerColumn } = SCALE_CONFIGS[scale];
594
754
  const originMs = viewportStart.getTime();
595
755
  const pxPerMs = columnWidth / msPerColumn;
596
756
  const msPerPx = msPerColumn / columnWidth;
597
- const msPerDay = 864e5;
757
+ const msPerHour = 36e5;
598
758
  return {
599
759
  originMs,
600
760
  columnWidth,
@@ -604,11 +764,11 @@ function createPixelMapper(scale, viewportStart) {
604
764
  toDate(x) {
605
765
  return new Date(originMs + x * msPerPx);
606
766
  },
607
- durationToWidth(days) {
608
- return days * msPerDay * pxPerMs;
767
+ durationToWidth(hours) {
768
+ return hours * msPerHour * pxPerMs;
609
769
  },
610
770
  widthToDuration(px) {
611
- return px * msPerPx / msPerDay;
771
+ return px * msPerPx / msPerHour;
612
772
  }
613
773
  };
614
774
  }
@@ -628,13 +788,17 @@ const MILESTONE_HALF = MILESTONE_SIZE / 2;
628
788
  /**
629
789
  * Computes pixel-space layout for all visible task rows.
630
790
  * Returns a map keyed by task id for O(1) lookup during link routing.
791
+ *
792
+ * @param rows - The flattened, visible {@link TaskNode} rows.
793
+ * @param mapper - The {@link PixelMapper} for coordinate conversion.
794
+ * @returns A `Map` from task ID to its computed {@link BarLayout}.
631
795
  */
632
796
  function computeLayout(rows, mapper) {
633
797
  const result = /* @__PURE__ */ new Map();
634
798
  for (let i = 0; i < rows.length; i++) {
635
799
  const task = rows[i];
636
800
  if (task === void 0) continue;
637
- const start = parseDate(task.start_date);
801
+ const start = parseDate(task.startDate);
638
802
  const x = mapper.toX(start);
639
803
  const y = i * ROW_HEIGHT + BAR_Y_OFFSET;
640
804
  const centerY = i * ROW_HEIGHT + ROW_HEIGHT / 2;
@@ -654,8 +818,8 @@ function computeLayout(rows, mapper) {
654
818
  });
655
819
  continue;
656
820
  }
657
- const width = Math.max(mapper.durationToWidth(task.duration), 4);
658
- const progressWidth = width * Math.min(1, Math.max(0, task.progress ?? 0));
821
+ const width = Math.max(mapper.durationToWidth(task.durationHours), 4);
822
+ const progressWidth = width * Math.min(1, Math.max(0, (task.percentComplete ?? 0) / 100));
659
823
  result.set(task.id, {
660
824
  taskId: task.id,
661
825
  x,
@@ -673,51 +837,39 @@ function computeLayout(rows, mapper) {
673
837
  }
674
838
  /**
675
839
  * Computes the total pixel height of all rows.
840
+ *
841
+ * @param rowCount - The number of visible rows.
842
+ * @returns The total pixel height (`rowCount * ROW_HEIGHT`).
676
843
  */
677
844
  function totalContentHeight(rowCount) {
678
845
  return rowCount * ROW_HEIGHT;
679
846
  }
680
847
  /**
681
848
  * Derives viewport bounds from task data with padding.
682
- * Returns [start, end] as UTC midnight dates.
849
+ *
850
+ * @param tasks - The task nodes to derive bounds from.
851
+ * @param paddingHours - Extra hours added before the earliest start and after the latest end. Defaults to `48`.
852
+ * @returns A tuple `[start, end]` of UTC midnight `Date` instances.
683
853
  */
684
- function deriveViewport(tasks, paddingDays = 2) {
854
+ function deriveViewport(tasks, paddingHours = 48) {
685
855
  if (tasks.length === 0) {
686
856
  const now = /* @__PURE__ */ new Date();
687
- return [now, addDays(now, 30)];
857
+ return [now, addHours(now, 720)];
688
858
  }
689
859
  let minMs = Infinity;
690
860
  let maxMs = -Infinity;
691
861
  for (const task of tasks) {
692
- const start = parseDate(task.start_date);
693
- const end = addDays(start, task.duration);
862
+ const start = parseDate(task.startDate);
863
+ const end = addHours(start, task.durationHours);
694
864
  if (start.getTime() < minMs) minMs = start.getTime();
695
865
  if (end.getTime() > maxMs) maxMs = end.getTime();
696
866
  }
697
- return [addDays(new Date(minMs), -paddingDays), addDays(new Date(maxMs), paddingDays)];
867
+ return [addHours(new Date(minMs), -paddingHours), addHours(new Date(maxMs), paddingHours)];
698
868
  }
699
869
  //#endregion
700
870
  //#region src/gantt-chart/rendering/linkRouter.ts
701
871
  const TURN_MARGIN = 12;
702
872
  /**
703
- * Computes orthogonal routing for all dependency links.
704
- * Links whose source or target is not in the layout map are skipped silently
705
- * (e.g. when the row is collapsed).
706
- */
707
- function routeLinks(links, layouts) {
708
- return links.map((link) => {
709
- const src = layouts.get(link.source);
710
- const tgt = layouts.get(link.target);
711
- if (src === void 0 || tgt === void 0) return null;
712
- return {
713
- linkId: link.id,
714
- sourceTaskId: link.source,
715
- targetTaskId: link.target,
716
- points: route(link.type, src, tgt)
717
- };
718
- }).filter((r) => r !== null);
719
- }
720
- /**
721
873
  * Produces the vertex list for an orthogonal connector between src and tgt.
722
874
  *
723
875
  * Link semantics:
@@ -725,6 +877,11 @@ function routeLinks(links, layouts) {
725
877
  * SS = source.start → target.start
726
878
  * FF = source.finish → target.finish
727
879
  * SF = source.start → target.finish
880
+ *
881
+ * @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.
728
885
  */
729
886
  function route(type, src, tgt) {
730
887
  let sx, tx;
@@ -804,8 +961,39 @@ function route(type, src, tgt) {
804
961
  }
805
962
  ];
806
963
  }
964
+ /**
965
+ * Computes orthogonal routing for all dependency links.
966
+ * Links whose source or target is not in the layout map are skipped silently
967
+ * (e.g. when the row is collapsed).
968
+ *
969
+ * @param links - The dependency links to route.
970
+ * @param layouts - A map from task ID to its computed {@link BarLayout}.
971
+ * @returns An array of {@link RoutedLink} objects with computed vertex paths.
972
+ */
973
+ function routeLinks(links, layouts) {
974
+ return links.map((link) => {
975
+ const src = layouts.get(link.source);
976
+ const tgt = layouts.get(link.target);
977
+ if (src === void 0 || tgt === void 0) return null;
978
+ return {
979
+ linkId: link.id,
980
+ sourceTaskId: link.source,
981
+ targetTaskId: link.target,
982
+ points: route(link.type, src, tgt)
983
+ };
984
+ }).filter((r) => r !== null);
985
+ }
807
986
  //#endregion
808
987
  //#region src/gantt-chart/vanilla/dom/helpers.ts
988
+ /**
989
+ * Batches style assignments; avoids repeated style recalculations.
990
+ *
991
+ * @param elem - The target element.
992
+ * @param styles - A partial CSS style declaration to apply.
993
+ */
994
+ function css(elem, styles) {
995
+ for (const [k, v] of Object.entries(styles)) elem.style[k] = v ?? "";
996
+ }
809
997
  function el(tag, props, ns) {
810
998
  const elem = ns ? document.createElementNS(ns, tag) : document.createElement(tag);
811
999
  if (props !== void 0) for (const [k, v] of Object.entries(props)) if (k === "style" && typeof v === "object" && v !== null) css(elem, v);
@@ -813,32 +1001,61 @@ function el(tag, props, ns) {
813
1001
  else elem.setAttribute(k, String(v));
814
1002
  return elem;
815
1003
  }
816
- /** Batches style assignments; avoids repeated style recalculations. */
817
- function css(elem, styles) {
818
- for (const [k, v] of Object.entries(styles)) elem.style[k] = v ?? "";
819
- }
820
- /** Removes all child nodes from elem. Faster than innerHTML = '' for large subtrees. */
1004
+ /**
1005
+ * Removes all child nodes from elem. Faster than `innerHTML = ''` for large subtrees.
1006
+ *
1007
+ * @param elem - The element to clear.
1008
+ */
821
1009
  function clearChildren(elem) {
822
1010
  while (elem.firstChild !== null) elem.removeChild(elem.firstChild);
823
1011
  }
824
- /** Appends all nodes from an array/fragment into parent in one pass. */
1012
+ /**
1013
+ * Appends all nodes from an array/fragment into parent in one pass.
1014
+ *
1015
+ * @param parent - The parent element.
1016
+ * @param children - The child elements or text nodes to append.
1017
+ */
825
1018
  function appendAll(parent, children) {
826
1019
  const frag = document.createDocumentFragment();
827
1020
  for (const c of children) frag.appendChild(c);
828
1021
  parent.append(frag);
829
1022
  }
830
- /** Sets multiple SVG attributes in one call. */
1023
+ /**
1024
+ * Sets multiple SVG attributes in one call.
1025
+ *
1026
+ * @param elem - The target SVG element.
1027
+ * @param attrs - Attributes to set (values are stringified).
1028
+ */
831
1029
  function setAttrs(elem, attrs) {
832
1030
  for (const [k, v] of Object.entries(attrs)) elem.setAttribute(k, String(v));
833
1031
  }
834
1032
  //#endregion
835
1033
  //#region src/gantt-chart/vanilla/dom/timeHeader.ts
1034
+ function specialDayKind(date, specialDaysByDate, showWeekends, weekendDays) {
1035
+ const dateKey = startOfDay(date).toISOString().slice(0, 10);
1036
+ const specialDay = specialDaysByDate.get(dateKey);
1037
+ if (specialDay !== void 0) return specialDay.kind;
1038
+ if (showWeekends && weekendDays.has(date.getUTCDay())) return "weekend";
1039
+ return null;
1040
+ }
1041
+ /**
1042
+ * Inline style helper local to this module.
1043
+ *
1044
+ * @param elem - The target element.
1045
+ * @param styles - A partial CSS style declaration to apply.
1046
+ */
1047
+ function css_(elem, styles) {
1048
+ for (const [k, v] of Object.entries(styles)) elem.style[k] = v ?? "";
1049
+ }
836
1050
  /**
837
1051
  * Fully replaces the content of `container` with two header rows.
838
1052
  * Called on scale change or viewport change only — not on scroll.
1053
+ *
1054
+ * @param container - The header container element to render into.
1055
+ * @param state - The current chart state.
839
1056
  */
840
1057
  function renderTimeHeader(container, state) {
841
- const { scale, viewportStart, viewportEnd, mapper, totalWidth, locale } = state;
1058
+ const { scale, viewportStart, viewportEnd, mapper, totalWidth, locale, showWeekends, weekendDays, specialDaysByDate } = state;
842
1059
  const weekStartsOn = locale.weekStartsOn ?? 1;
843
1060
  const upperCells = [];
844
1061
  const lowerCells = [];
@@ -853,7 +1070,8 @@ function renderTimeHeader(container, state) {
853
1070
  lowerCells.push({
854
1071
  label: formatHeaderLabel(cur, scale, locale),
855
1072
  x,
856
- width: w
1073
+ width: w,
1074
+ date: new Date(cur)
857
1075
  });
858
1076
  const uLabel = formatUpperLabel(cur, scale, locale);
859
1077
  if (uLabel !== prevUpperLabel) {
@@ -928,6 +1146,19 @@ function renderTimeHeader(container, state) {
928
1146
  whiteSpace: "nowrap"
929
1147
  });
930
1148
  d.textContent = cell.label;
1149
+ if (scale === "day") {
1150
+ const kind = specialDayKind(cell.date, specialDaysByDate, showWeekends, weekendDays);
1151
+ if (kind !== null) {
1152
+ d.classList.add(`gantt-header-cell--${kind}`);
1153
+ const dateKey = startOfDay(cell.date).toISOString().slice(0, 10);
1154
+ d.dataset["date"] = dateKey;
1155
+ const specialDay = specialDaysByDate.get(dateKey);
1156
+ if (specialDay?.label !== void 0) {
1157
+ d.dataset["label"] = specialDay.label;
1158
+ d.title = specialDay.label;
1159
+ }
1160
+ }
1161
+ }
931
1162
  return d;
932
1163
  });
933
1164
  appendAll(upperRow, upperNodes);
@@ -936,10 +1167,6 @@ function renderTimeHeader(container, state) {
936
1167
  container.append(upperRow);
937
1168
  container.append(lowerRow);
938
1169
  }
939
- /** Inline style helper local to this module. */
940
- function css_(elem, styles) {
941
- for (const [k, v] of Object.entries(styles)) elem.style[k] = v ?? "";
942
- }
943
1170
  //#endregion
944
1171
  //#region src/gantt-chart/vanilla/dom/gridColumns.ts
945
1172
  const DEFAULT_GRID_COLUMNS = [
@@ -949,17 +1176,17 @@ const DEFAULT_GRID_COLUMNS = [
949
1176
  width: "1fr"
950
1177
  },
951
1178
  {
952
- id: "start_date",
1179
+ id: "startDate",
953
1180
  header: "Start time",
954
1181
  width: "90px",
955
- field: "start_date",
1182
+ field: "startDate",
956
1183
  format: (value, _task, _row, locale) => formatDisplayDate(String(value), locale)
957
1184
  },
958
1185
  {
959
- id: "duration",
1186
+ id: "durationHours",
960
1187
  header: "Duration",
961
1188
  width: "68px",
962
- field: "duration",
1189
+ field: "durationHours",
963
1190
  format: (value) => value > 0 ? String(value) : "—"
964
1191
  },
965
1192
  {
@@ -970,27 +1197,30 @@ const DEFAULT_GRID_COLUMNS = [
970
1197
  ];
971
1198
  /**
972
1199
  * Returns a localized default grid column schema.
973
- * Column headers use locale label overrides with EN_US_LABELS fallback.
1200
+ * Column headers use locale label overrides with `EN_US_LABELS` fallback.
1201
+ *
1202
+ * @param locale - The {@link ChartLocale} to derive column header labels from.
1203
+ * @returns An array of {@link GridColumn} objects.
974
1204
  */
975
1205
  function gridColumnDefaults(locale) {
976
1206
  return [
977
1207
  {
978
1208
  id: "name",
979
- header: locale.labels?.column_task_name ?? EN_US_LABELS.column_task_name,
1209
+ header: locale.labels?.columnTaskName ?? EN_US_LABELS.columnTaskName,
980
1210
  width: "1fr"
981
1211
  },
982
1212
  {
983
- id: "start_date",
984
- header: locale.labels?.column_start_time ?? EN_US_LABELS.column_start_time,
1213
+ id: "startDate",
1214
+ header: locale.labels?.columnStartDate ?? EN_US_LABELS.columnStartDate,
985
1215
  width: "90px",
986
- field: "start_date",
1216
+ field: "startDate",
987
1217
  format: (value, _task, _row, loc) => formatDisplayDate(String(value), loc)
988
1218
  },
989
1219
  {
990
- id: "duration",
991
- header: locale.labels?.column_duration ?? EN_US_LABELS.column_duration,
1220
+ id: "durationHours",
1221
+ header: locale.labels?.columnDuration ?? EN_US_LABELS.columnDuration,
992
1222
  width: "68px",
993
- field: "duration",
1223
+ field: "durationHours",
994
1224
  format: (value) => value > 0 ? String(value) : "—"
995
1225
  },
996
1226
  {
@@ -1000,9 +1230,21 @@ function gridColumnDefaults(locale) {
1000
1230
  }
1001
1231
  ];
1002
1232
  }
1233
+ /**
1234
+ * Builds a CSS `grid-template-columns` value from a column schema.
1235
+ *
1236
+ * @param columns - The full column schema array (only visible columns are included).
1237
+ * @returns A space-separated CSS track list.
1238
+ */
1003
1239
  function gridTemplateColumns(columns) {
1004
1240
  return columns.filter((c) => c.visible !== false).map((c) => c.width).join(" ");
1005
1241
  }
1242
+ /**
1243
+ * Filters a column schema to only visible columns.
1244
+ *
1245
+ * @param columns - The full column schema array.
1246
+ * @returns A new array containing only columns where `visible` is not `false`.
1247
+ */
1006
1248
  function visibleColumns(columns) {
1007
1249
  return columns.filter((c) => c.visible !== false);
1008
1250
  }
@@ -1017,6 +1259,13 @@ function parseColumnMinWidth(width) {
1017
1259
  if (frMatch) return parseFloat(frMatch[1] ?? "0") * 120;
1018
1260
  return 0;
1019
1261
  }
1262
+ /**
1263
+ * Computes the minimum natural pixel width of a grid column schema.
1264
+ *
1265
+ * @param columns - The full column schema array.
1266
+ * @returns The sum of minimum widths: `px` columns sum directly, `fr` units contribute
1267
+ * `GRID_COLUMN_FR_MIN_WIDTH` px each.
1268
+ */
1020
1269
  function gridNaturalWidth(columns) {
1021
1270
  let total = 0;
1022
1271
  for (const col of visibleColumns(columns)) total += parseColumnMinWidth(col.width);
@@ -1026,65 +1275,18 @@ function gridNaturalWidth(columns) {
1026
1275
  //#region src/gantt-chart/vanilla/dom/leftPane.ts
1027
1276
  const INDENT = 16;
1028
1277
  const COLUMN_MIN_WIDTH = 30;
1029
- /** Renders the left grid pane. */
1030
- function renderLeftPane(container, state, cbs, columns) {
1031
- const { allRows, selectedId, expandedIds, startIndex, endIndex, paddingTop, paddingBottom, locale } = state;
1032
- const frag = document.createDocumentFragment();
1033
- if (paddingTop > 0) {
1034
- const spacer = el("div");
1035
- spacer.style.height = `${paddingTop}px`;
1036
- frag.append(spacer);
1037
- }
1038
- for (const row of allRows.slice(startIndex, endIndex + 1)) frag.append(buildRow(row, selectedId, expandedIds, cbs, columns, locale));
1039
- if (paddingBottom > 0) {
1040
- const spacer = el("div");
1041
- spacer.style.height = `${paddingBottom}px`;
1042
- frag.append(spacer);
1043
- }
1044
- clearChildren(container);
1045
- container.append(frag);
1046
- }
1047
- function buildRow(row, selectedId, expandedIds, cbs, columns, locale) {
1048
- const selected = row.id === selectedId;
1049
- const wrapper = el("div");
1050
- wrapper.className = "gantt-row";
1051
- css(wrapper, {
1052
- display: "grid",
1053
- gridTemplateColumns: gridTemplateColumns(columns),
1054
- height: `${ROW_HEIGHT}px`,
1055
- alignItems: "center",
1056
- paddingLeft: "8px",
1057
- background: selected ? "var(--gantt-row-selected)" : "var(--gantt-bg)",
1058
- borderBottom: "1px solid var(--gantt-border)",
1059
- cursor: "default",
1060
- boxSizing: "border-box"
1061
- });
1062
- wrapper.tabIndex = 0;
1063
- wrapper.setAttribute("role", "row");
1064
- wrapper.setAttribute("aria-selected", String(selected));
1065
- wrapper.dataset["taskId"] = String(row.id);
1066
- wrapper.addEventListener("click", () => {
1067
- const task = toTask$1(row);
1068
- cbs.onRowClick({
1069
- id: row.id,
1070
- task
1071
- });
1072
- });
1073
- wrapper.addEventListener("keydown", (event) => {
1074
- if (event.key === "Enter" || event.key === " ") {
1075
- event.preventDefault();
1076
- cbs.onSelect(row.id);
1077
- }
1078
- });
1079
- for (const column of visibleColumns(columns)) wrapper.append(buildCell(column, row, expandedIds, cbs, locale));
1080
- return wrapper;
1081
- }
1082
- function buildCell(column, row, expandedIds, cbs, locale) {
1083
- switch (column.id) {
1084
- case "name": return buildTreeNameCell(row, expandedIds, cbs);
1085
- case "actions": return buildAddButton(row, cbs, locale);
1086
- default: return buildDataCell(row, column, locale);
1087
- }
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 }
1289
+ };
1088
1290
  }
1089
1291
  function buildTreeNameCell(row, expandedIds, cbs) {
1090
1292
  const hasChildren = isParent(row);
@@ -1162,7 +1364,7 @@ function buildAddButton(row, cbs, locale) {
1162
1364
  const btn = el("button");
1163
1365
  btn.className = "gantt-add-btn";
1164
1366
  btn.textContent = "+";
1165
- btn.title = locale.labels?.add_subtask_title ?? EN_US_LABELS.add_subtask_title;
1367
+ btn.title = locale.labels?.addSubtaskTitle ?? EN_US_LABELS.addSubtaskTitle;
1166
1368
  css(btn, {
1167
1369
  background: "none",
1168
1370
  border: "none",
@@ -1177,20 +1379,79 @@ function buildAddButton(row, cbs, locale) {
1177
1379
  });
1178
1380
  return btn;
1179
1381
  }
1180
- function toTask$1(row) {
1181
- return {
1182
- id: row.id,
1183
- text: row.text,
1184
- start_date: row.start_date,
1185
- duration: row.duration,
1186
- progress: row.progress,
1187
- type: row.type,
1188
- open: row.open,
1189
- ...row.parent === void 0 ? {} : { parent: row.parent },
1190
- ...row.color === void 0 ? {} : { color: row.color }
1191
- };
1382
+ function buildCell(column, row, expandedIds, cbs, locale) {
1383
+ switch (column.id) {
1384
+ case "name": return buildTreeNameCell(row, expandedIds, cbs);
1385
+ case "actions": return buildAddButton(row, cbs, locale);
1386
+ default: return buildDataCell(row, column, locale);
1387
+ }
1388
+ }
1389
+ function buildRow(row, selectedId, expandedIds, cbs, columns, locale) {
1390
+ const selected = row.id === selectedId;
1391
+ const wrapper = el("div");
1392
+ wrapper.className = "gantt-row";
1393
+ css(wrapper, {
1394
+ display: "grid",
1395
+ gridTemplateColumns: gridTemplateColumns(columns),
1396
+ height: `${ROW_HEIGHT}px`,
1397
+ alignItems: "center",
1398
+ paddingLeft: "8px",
1399
+ background: selected ? "var(--gantt-row-selected)" : "var(--gantt-bg)",
1400
+ borderBottom: "1px solid var(--gantt-border)",
1401
+ cursor: "default",
1402
+ boxSizing: "border-box"
1403
+ });
1404
+ wrapper.tabIndex = 0;
1405
+ wrapper.setAttribute("role", "row");
1406
+ wrapper.setAttribute("aria-selected", String(selected));
1407
+ wrapper.dataset["taskId"] = String(row.id);
1408
+ wrapper.addEventListener("click", () => {
1409
+ const task = toTask$1(row);
1410
+ cbs.onRowClick({
1411
+ id: row.id,
1412
+ task
1413
+ });
1414
+ });
1415
+ wrapper.addEventListener("keydown", (event) => {
1416
+ if (event.key === "Enter" || event.key === " ") {
1417
+ event.preventDefault();
1418
+ cbs.onSelect(row.id);
1419
+ }
1420
+ });
1421
+ for (const column of visibleColumns(columns)) wrapper.append(buildCell(column, row, expandedIds, cbs, locale));
1422
+ return wrapper;
1423
+ }
1424
+ /**
1425
+ * Renders the left grid pane.
1426
+ *
1427
+ * @param container - The left pane body element to render into.
1428
+ * @param state - The current chart state.
1429
+ * @param cbs - The left pane callbacks.
1430
+ * @param columns - The grid column schema.
1431
+ */
1432
+ function renderLeftPane(container, state, cbs, columns) {
1433
+ const { allRows, selectedId, expandedIds, startIndex, endIndex, paddingTop, paddingBottom, locale } = state;
1434
+ const frag = document.createDocumentFragment();
1435
+ if (paddingTop > 0) {
1436
+ const spacer = el("div");
1437
+ spacer.style.height = `${paddingTop}px`;
1438
+ frag.append(spacer);
1439
+ }
1440
+ for (const row of allRows.slice(startIndex, endIndex + 1)) frag.append(buildRow(row, selectedId, expandedIds, cbs, columns, locale));
1441
+ if (paddingBottom > 0) {
1442
+ const spacer = el("div");
1443
+ spacer.style.height = `${paddingBottom}px`;
1444
+ frag.append(spacer);
1445
+ }
1446
+ clearChildren(container);
1447
+ container.append(frag);
1192
1448
  }
1193
- /** Builds the header row for the left pane. */
1449
+ /**
1450
+ * Builds the header row for the left pane.
1451
+ *
1452
+ * @param columns - The grid column schema.
1453
+ * @returns The header DOM element.
1454
+ */
1194
1455
  function buildLeftPaneHeader(columns) {
1195
1456
  const header = el("div");
1196
1457
  css(header, {
@@ -1246,8 +1507,13 @@ function buildLeftPaneHeader(columns) {
1246
1507
  }
1247
1508
  /**
1248
1509
  * Wires up column resize interactions on header handles.
1249
- * Must be called after the header is in the DOM (so getBoundingClientRect works).
1250
- * Returns a cleanup function.
1510
+ * Must be called after the header is in the DOM (so `getBoundingClientRect` works).
1511
+ *
1512
+ * @param headerEl - The header element containing resize handles.
1513
+ * @param bodyEl - The body element whose rows share the column widths.
1514
+ * @param columns - The grid column schema (mutated in place on resize end).
1515
+ * @param onChange - Optional callback fired on drag end with updated columns.
1516
+ * @returns A cleanup function that removes all resize listeners.
1251
1517
  */
1252
1518
  function setupColumnResize(headerEl, bodyEl, columns, onChange) {
1253
1519
  const handles = headerEl.querySelectorAll(".gantt-col-resize-handle");
@@ -1306,6 +1572,10 @@ const ARROW_SIZE = 6;
1306
1572
  /**
1307
1573
  * Creates the SVG overlay element. Call once; pass to updateDependencyLayer on each render.
1308
1574
  * Also creates a hidden ghost-line path used during link-creation drags.
1575
+ *
1576
+ * @param totalWidth - The total pixel width of the SVG viewport.
1577
+ * @param totalHeight - The total pixel height of the SVG viewport.
1578
+ * @returns An `SVGSVGElement` ready to be inserted into the DOM.
1309
1579
  */
1310
1580
  function createDependencyLayer(totalWidth, totalHeight) {
1311
1581
  const svg = document.createElementNS(NS, "svg");
@@ -1357,7 +1627,13 @@ function createDependencyLayer(totalWidth, totalHeight) {
1357
1627
  }
1358
1628
  /**
1359
1629
  * Shows or updates the ghost line drawn during a link-creation drag.
1360
- * Pass valid=true when the pointer is over a valid target bar.
1630
+ *
1631
+ * @param svg - The SVG dependency layer element.
1632
+ * @param x1 - Start X coordinate.
1633
+ * @param y1 - Start Y coordinate.
1634
+ * @param x2 - End X coordinate.
1635
+ * @param y2 - End Y coordinate.
1636
+ * @param valid - When `true`, the line is drawn solid with an arrow marker.
1361
1637
  */
1362
1638
  function showGhostLine(svg, x1, y1, x2, y2, valid) {
1363
1639
  const ghost = svg.querySelector("path.gantt-ghost-line");
@@ -1372,6 +1648,8 @@ function showGhostLine(svg, x1, y1, x2, y2, valid) {
1372
1648
  }
1373
1649
  /**
1374
1650
  * Hides the ghost line after a link-creation drag completes or is cancelled.
1651
+ *
1652
+ * @param svg - The SVG dependency layer element.
1375
1653
  */
1376
1654
  function hideGhostLine(svg) {
1377
1655
  const ghost = svg.querySelector("path.gantt-ghost-line");
@@ -1383,6 +1661,13 @@ function hideGhostLine(svg) {
1383
1661
  /**
1384
1662
  * Replaces all path elements in the SVG to reflect the current link set.
1385
1663
  * The `<defs>` node (first child) is preserved.
1664
+ *
1665
+ * @param svg - The SVG dependency layer element.
1666
+ * @param links - The array of routed links to render.
1667
+ * @param totalWidth - The total pixel width of the SVG viewport.
1668
+ * @param totalHeight - The total pixel height of the SVG viewport.
1669
+ * @param selectedTaskId - The currently selected task ID, or `null`.
1670
+ * @param highlightLinkedDependenciesOnSelect - When `true`, links connected to the selected task use highlight styling.
1386
1671
  */
1387
1672
  function updateDependencyLayer(svg, links, totalWidth, totalHeight, selectedTaskId, highlightLinkedDependenciesOnSelect) {
1388
1673
  setAttrs(svg, {
@@ -1418,12 +1703,31 @@ function updateDependencyLayer(svg, links, totalWidth, totalHeight, selectedTask
1418
1703
  }
1419
1704
  //#endregion
1420
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 }
1717
+ };
1718
+ }
1421
1719
  /**
1422
1720
  * Attaches drag-to-move and resize listeners to a bar element.
1423
- * Returns a cleanup function that removes all listeners.
1424
1721
  *
1425
1722
  * Design: all mutable state lives in closure variables captured at mousedown.
1426
1723
  * No global state; multiple bars can be dragged independently (one at a time).
1724
+ *
1725
+ * @param barEl - The bar DOM element.
1726
+ * @param resizeHandleEl - The resize handle DOM element.
1727
+ * @param task - The {@link TaskNode} for this bar.
1728
+ * @param getMapper - A function returning the current {@link PixelMapper} (snapshotted at mousedown).
1729
+ * @param cbs - The chart callbacks.
1730
+ * @returns A cleanup function that removes all listeners.
1427
1731
  */
1428
1732
  function attachDrag(barEl, resizeHandleEl, task, getMapper, cbs) {
1429
1733
  function onBarDown(e) {
@@ -1434,14 +1738,14 @@ function attachDrag(barEl, resizeHandleEl, task, getMapper, cbs) {
1434
1738
  } catch {}
1435
1739
  cbs.onSelect?.(task.id);
1436
1740
  const startX = e.clientX;
1437
- const originDate = parseDate(task.start_date);
1741
+ const originDate = parseDate(task.startDate);
1438
1742
  const mapper = getMapper();
1439
1743
  function onMove(me) {
1440
1744
  const dx = me.clientX - startX;
1441
- const days = Math.round(mapper.widthToDuration(dx));
1745
+ const hours = Math.round(mapper.widthToDuration(dx));
1442
1746
  cbs.onMove?.({
1443
1747
  id: task.id,
1444
- startDate: addDays(originDate, days)
1748
+ startDate: addHours(originDate, hours)
1445
1749
  });
1446
1750
  }
1447
1751
  function onUp() {
@@ -1461,14 +1765,14 @@ function attachDrag(barEl, resizeHandleEl, task, getMapper, cbs) {
1461
1765
  resizeHandleEl.setPointerCapture(e.pointerId);
1462
1766
  } catch {}
1463
1767
  const startX = e.clientX;
1464
- const origDur = task.duration;
1768
+ const origDur = task.durationHours;
1465
1769
  const mapper = getMapper();
1466
1770
  function onMove(me) {
1467
1771
  const dx = me.clientX - startX;
1468
- const daysDelta = Math.round(mapper.widthToDuration(dx));
1772
+ const hoursDelta = Math.round(mapper.widthToDuration(dx));
1469
1773
  cbs.onResize?.({
1470
1774
  id: task.id,
1471
- duration: Math.max(1, origDur + daysDelta)
1775
+ durationHours: Math.max(1, origDur + hoursDelta)
1472
1776
  });
1473
1777
  }
1474
1778
  function onUp() {
@@ -1483,7 +1787,7 @@ function attachDrag(barEl, resizeHandleEl, task, getMapper, cbs) {
1483
1787
  cbs.onTaskEditIntent?.({
1484
1788
  id: task.id,
1485
1789
  source: "bar",
1486
- trigger: "double_click",
1790
+ trigger: "doubleClick",
1487
1791
  task: toTask(task)
1488
1792
  });
1489
1793
  }
@@ -1498,7 +1802,11 @@ function attachDrag(barEl, resizeHandleEl, task, getMapper, cbs) {
1498
1802
  }
1499
1803
  /**
1500
1804
  * Attaches click-to-select on a milestone diamond.
1501
- * Returns cleanup.
1805
+ *
1806
+ * @param diamondEl - The milestone diamond DOM element.
1807
+ * @param taskId - The task ID to select.
1808
+ * @param cbs - The chart callbacks.
1809
+ * @returns A cleanup function that removes all listeners.
1502
1810
  */
1503
1811
  function attachMilestoneClick(diamondEl, taskId, cbs) {
1504
1812
  function onClick() {
@@ -1511,7 +1819,7 @@ function attachMilestoneClick(diamondEl, taskId, cbs) {
1511
1819
  cbs.onTaskEditIntent?.({
1512
1820
  id: taskId,
1513
1821
  source: "milestone",
1514
- trigger: "double_click",
1822
+ trigger: "doubleClick",
1515
1823
  task
1516
1824
  });
1517
1825
  }
@@ -1526,24 +1834,19 @@ function attachMilestoneClick(diamondEl, taskId, cbs) {
1526
1834
  function bindMilestoneTask(diamondEl, task) {
1527
1835
  diamondEl.__task = task;
1528
1836
  }
1529
- function toTask(row) {
1530
- return {
1531
- id: row.id,
1532
- text: row.text,
1533
- start_date: row.start_date,
1534
- duration: row.duration,
1535
- progress: row.progress,
1536
- type: row.type,
1537
- open: row.open,
1538
- ...row.parent === void 0 ? {} : { parent: row.parent },
1539
- ...row.color === void 0 ? {} : { color: row.color }
1540
- };
1541
- }
1542
1837
  //#endregion
1543
1838
  //#region src/gantt-chart/vanilla/interaction/linkCreation.ts
1544
1839
  /**
1545
1840
  * Attaches a link-creation drag listener to an endpoint handle.
1546
- * Returns a cleanup function that removes all listeners.
1841
+ *
1842
+ * @param handle - The endpoint DOM element.
1843
+ * @param sourceTaskId - The task ID from which the link originates.
1844
+ * @param anchorX - The X anchor coordinate (task center).
1845
+ * @param anchorY - The Y anchor coordinate (task center).
1846
+ * @param svgLayer - The SVG dependency layer element for ghost line rendering.
1847
+ * @param absoluteLayer - The absolute-positioned layer for coordinate calculations.
1848
+ * @param cbs - The chart callbacks.
1849
+ * @returns A cleanup function that removes all listeners.
1547
1850
  */
1548
1851
  function attachLinkEndpointHandle(handle, sourceTaskId, anchorX, anchorY, svgLayer, absoluteLayer, cbs) {
1549
1852
  function onPointerDown(e) {
@@ -1592,6 +1895,8 @@ function attachLinkEndpointHandle(handle, sourceTaskId, anchorX, anchorY, svgLay
1592
1895
  /**
1593
1896
  * Creates an endpoint handle DOM element.
1594
1897
  * The caller must position it with inline styles and append it to the layer.
1898
+ *
1899
+ * @returns A new `HTMLElement` with the `gantt-link-endpoint` class.
1595
1900
  */
1596
1901
  function createEndpointHandle() {
1597
1902
  const handle = document.createElement("div");
@@ -1618,7 +1923,11 @@ const BAR_COLOR = {
1618
1923
  project: "var(--gantt-project)",
1619
1924
  milestone: "var(--gantt-milestone)"
1620
1925
  };
1621
- /** Creates the skeleton DOM structure for the right pane. Call once. */
1926
+ /**
1927
+ * Creates the skeleton DOM structure for the right pane. Call once.
1928
+ *
1929
+ * @returns A new {@link RightPaneRefs} with empty containers and bar registry.
1930
+ */
1622
1931
  function createRightPaneRefs() {
1623
1932
  const scrollContainer = el("div");
1624
1933
  const stripeContainer = el("div");
@@ -1627,7 +1936,7 @@ function createRightPaneRefs() {
1627
1936
  css(stripeContainer, { position: "relative" });
1628
1937
  css(absoluteLayer, {
1629
1938
  position: "absolute",
1630
- top: "0",
1939
+ top: "52px",
1631
1940
  left: "0"
1632
1941
  });
1633
1942
  scrollContainer.append(stripeContainer);
@@ -1641,94 +1950,8 @@ function createRightPaneRefs() {
1641
1950
  barRegistry: /* @__PURE__ */ new Map()
1642
1951
  };
1643
1952
  }
1644
- /**
1645
- * Full render of the right pane.
1646
- * Grid lines and stripes are rebuilt each call (cheap — no event listeners).
1647
- * Bars are rebuilt each call with fresh drag listeners (old ones cleaned up first).
1648
- */
1649
- function renderRightPane(refs, state, cbs) {
1650
- const { allRows, layouts, links, mapper, scale, viewportStart, viewportEnd, totalWidth, selectedId, highlightLinkedDependenciesOnSelect, paddingTop, paddingBottom, startIndex } = state;
1651
- const { stripeContainer, absoluteLayer, svgLayer, barRegistry } = refs;
1652
- const rowCount = allRows.length;
1653
- const contentHeight = totalContentHeight(rowCount);
1654
- const visibleRows = allRows.slice(state.startIndex, state.endIndex + 1);
1655
- clearChildren(stripeContainer);
1656
- css(stripeContainer, { width: `${totalWidth}px` });
1657
- if (paddingTop > 0) {
1658
- const s = el("div");
1659
- s.style.height = `${paddingTop}px`;
1660
- stripeContainer.append(s);
1661
- }
1662
- for (let i = 0; i < visibleRows.length; i++) {
1663
- const rowIdx = startIndex + i;
1664
- const stripe = el("div");
1665
- css(stripe, {
1666
- height: `${ROW_HEIGHT}px`,
1667
- background: rowIdx % 2 === 0 ? "var(--gantt-bg)" : "var(--gantt-stripe)",
1668
- borderBottom: "1px solid var(--gantt-border)"
1669
- });
1670
- stripeContainer.append(stripe);
1671
- }
1672
- if (paddingBottom > 0) {
1673
- const s = el("div");
1674
- s.style.height = `${paddingBottom}px`;
1675
- stripeContainer.append(s);
1676
- }
1677
- css(absoluteLayer, {
1678
- width: `${totalWidth}px`,
1679
- height: `${contentHeight}px`
1680
- });
1681
- const toRemove = [];
1682
- for (const child of [...absoluteLayer.children]) if (child !== svgLayer) toRemove.push(child);
1683
- for (const node of toRemove) absoluteLayer.removeChild(node);
1684
- hideGhostLine(svgLayer);
1685
- for (const { cleanupDrag, cleanupLinkHandles } of barRegistry.values()) {
1686
- cleanupDrag();
1687
- cleanupLinkHandles?.();
1688
- }
1689
- barRegistry.clear();
1690
- if (scale === "day") renderSpecialDayBackgrounds(absoluteLayer, svgLayer, state, contentHeight);
1691
- let gridCur = snapToScaleBoundary(viewportStart, scale);
1692
- while (gridCur <= viewportEnd) {
1693
- const x = mapper.toX(gridCur);
1694
- const line = el("div");
1695
- css(line, {
1696
- position: "absolute",
1697
- left: `${x}px`,
1698
- top: "0",
1699
- width: "1px",
1700
- height: `${contentHeight}px`,
1701
- background: "var(--gantt-grid-line)",
1702
- pointerEvents: "none"
1703
- });
1704
- absoluteLayer.insertBefore(line, svgLayer);
1705
- gridCur = nextScaleBoundary(gridCur, scale);
1706
- }
1707
- const todayX = mapper.toX(/* @__PURE__ */ new Date());
1708
- const todayLineWidth = 2;
1709
- if (todayX >= 0 && todayX <= totalWidth - todayLineWidth) {
1710
- const todayLine = el("div");
1711
- todayLine.className = "gantt-today-marker";
1712
- css(todayLine, {
1713
- position: "absolute",
1714
- left: `${todayX}px`,
1715
- top: "0",
1716
- width: `${todayLineWidth}px`,
1717
- height: `${contentHeight}px`,
1718
- background: "var(--gantt-today)",
1719
- pointerEvents: "none",
1720
- zIndex: "5"
1721
- });
1722
- absoluteLayer.insertBefore(todayLine, svgLayer);
1723
- }
1724
- const visibleTaskIds = new Set(visibleRows.map((task) => task.id));
1725
- for (const task of visibleRows) {
1726
- const layout = layouts.get(task.id);
1727
- if (layout === void 0) continue;
1728
- if (layout.type === "milestone") renderMilestone(absoluteLayer, svgLayer, task, layout, selectedId, barRegistry, cbs, state);
1729
- else renderBar(absoluteLayer, svgLayer, task, layout, selectedId, barRegistry, state, cbs);
1730
- }
1731
- updateDependencyLayer(svgLayer, links.filter((link) => visibleTaskIds.has(link.sourceTaskId) && visibleTaskIds.has(link.targetTaskId)), totalWidth, contentHeight, selectedId, highlightLinkedDependenciesOnSelect);
1953
+ function ariaLabel(locale, key, arg) {
1954
+ return formatLabel(locale.labels?.[key] ?? EN_US_LABELS[key], arg);
1732
1955
  }
1733
1956
  function renderSpecialDayBackgrounds(layer, beforeNode, state, contentHeight) {
1734
1957
  const { mapper, viewportStart, viewportEnd, showWeekends, weekendDays, specialDaysByDate } = state;
@@ -1820,7 +2043,7 @@ function renderBar(layer, svgLayer, task, layout, selectedId, registry, state, c
1820
2043
  bar.append(label);
1821
2044
  bar.tabIndex = 0;
1822
2045
  bar.setAttribute("role", "button");
1823
- bar.setAttribute("aria-label", ariaLabel(state.locale, "aria_task", task.text));
2046
+ bar.setAttribute("aria-label", ariaLabel(state.locale, "ariaTask", task.text));
1824
2047
  bar.setAttribute("aria-pressed", String(selected));
1825
2048
  bar.dataset["taskId"] = String(task.id);
1826
2049
  bar.addEventListener("click", () => {
@@ -1907,7 +2130,7 @@ function renderMilestone(layer, svgLayer, task, layout, selectedId, registry, cb
1907
2130
  });
1908
2131
  diamond.tabIndex = 0;
1909
2132
  diamond.setAttribute("role", "button");
1910
- diamond.setAttribute("aria-label", ariaLabel(state.locale, "aria_milestone", task.text));
2133
+ diamond.setAttribute("aria-label", ariaLabel(state.locale, "ariaMilestone", task.text));
1911
2134
  diamond.setAttribute("aria-pressed", String(selected));
1912
2135
  diamond.dataset["taskId"] = String(task.id);
1913
2136
  diamond.addEventListener("keydown", (event) => {
@@ -1967,8 +2190,98 @@ function renderMilestone(layer, svgLayer, task, layout, selectedId, registry, cb
1967
2190
  if (cleanupLinkHandles !== void 0) entry.cleanupLinkHandles = cleanupLinkHandles;
1968
2191
  registry.set(task.id, entry);
1969
2192
  }
1970
- function ariaLabel(locale, key, arg) {
1971
- return formatLabel(locale.labels?.[key] ?? EN_US_LABELS[key], arg);
2193
+ /**
2194
+ * Full render of the right pane.
2195
+ * Grid lines and stripes are rebuilt each call (cheap — no event listeners).
2196
+ * Bars are rebuilt each call with fresh drag listeners (old ones cleaned up first).
2197
+ *
2198
+ * @param refs - The right pane DOM references.
2199
+ * @param state - The current chart state.
2200
+ * @param cbs - The chart callbacks.
2201
+ */
2202
+ function renderRightPane(refs, state, cbs) {
2203
+ const { allRows, layouts, links, mapper, scale, viewportStart, viewportEnd, totalWidth, selectedId, highlightLinkedDependenciesOnSelect, paddingTop, paddingBottom, startIndex } = state;
2204
+ const { stripeContainer, absoluteLayer, svgLayer, barRegistry } = refs;
2205
+ const rowCount = allRows.length;
2206
+ const contentHeight = totalContentHeight(rowCount);
2207
+ const visibleRows = allRows.slice(state.startIndex, state.endIndex + 1);
2208
+ clearChildren(stripeContainer);
2209
+ css(stripeContainer, { width: `${totalWidth}px` });
2210
+ if (paddingTop > 0) {
2211
+ const s = el("div");
2212
+ s.style.height = `${paddingTop}px`;
2213
+ stripeContainer.append(s);
2214
+ }
2215
+ for (let i = 0; i < visibleRows.length; i++) {
2216
+ const rowIdx = startIndex + i;
2217
+ const stripe = el("div");
2218
+ css(stripe, {
2219
+ height: `${ROW_HEIGHT}px`,
2220
+ background: rowIdx % 2 === 0 ? "var(--gantt-bg)" : "var(--gantt-stripe)",
2221
+ borderBottom: "1px solid var(--gantt-border)"
2222
+ });
2223
+ stripeContainer.append(stripe);
2224
+ }
2225
+ if (paddingBottom > 0) {
2226
+ const s = el("div");
2227
+ s.style.height = `${paddingBottom}px`;
2228
+ stripeContainer.append(s);
2229
+ }
2230
+ css(absoluteLayer, {
2231
+ width: `${totalWidth}px`,
2232
+ height: `${contentHeight}px`
2233
+ });
2234
+ const toRemove = [];
2235
+ for (const child of [...absoluteLayer.children]) if (child !== svgLayer) toRemove.push(child);
2236
+ for (const node of toRemove) absoluteLayer.removeChild(node);
2237
+ hideGhostLine(svgLayer);
2238
+ for (const { cleanupDrag, cleanupLinkHandles } of barRegistry.values()) {
2239
+ cleanupDrag();
2240
+ cleanupLinkHandles?.();
2241
+ }
2242
+ barRegistry.clear();
2243
+ if (scale === "day") renderSpecialDayBackgrounds(absoluteLayer, svgLayer, state, contentHeight);
2244
+ let gridCur = snapToScaleBoundary(viewportStart, scale);
2245
+ while (gridCur <= viewportEnd) {
2246
+ const x = mapper.toX(gridCur);
2247
+ const line = el("div");
2248
+ css(line, {
2249
+ position: "absolute",
2250
+ left: `${x}px`,
2251
+ top: "0",
2252
+ width: "1px",
2253
+ height: `${contentHeight}px`,
2254
+ background: "var(--gantt-grid-line)",
2255
+ pointerEvents: "none"
2256
+ });
2257
+ absoluteLayer.insertBefore(line, svgLayer);
2258
+ gridCur = nextScaleBoundary(gridCur, scale);
2259
+ }
2260
+ const todayX = mapper.toX(/* @__PURE__ */ new Date());
2261
+ const todayLineWidth = 2;
2262
+ if (todayX >= 0 && todayX <= totalWidth - todayLineWidth) {
2263
+ const todayLine = el("div");
2264
+ todayLine.className = "gantt-today-marker";
2265
+ css(todayLine, {
2266
+ position: "absolute",
2267
+ left: `${todayX}px`,
2268
+ top: "0",
2269
+ width: `${todayLineWidth}px`,
2270
+ height: `${contentHeight}px`,
2271
+ background: "var(--gantt-today)",
2272
+ pointerEvents: "none",
2273
+ zIndex: "5"
2274
+ });
2275
+ absoluteLayer.insertBefore(todayLine, svgLayer);
2276
+ }
2277
+ const visibleTaskIds = new Set(visibleRows.map((task) => task.id));
2278
+ for (const task of visibleRows) {
2279
+ const layout = layouts.get(task.id);
2280
+ 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);
2283
+ }
2284
+ updateDependencyLayer(svgLayer, links.filter((link) => visibleTaskIds.has(link.sourceTaskId) && visibleTaskIds.has(link.targetTaskId)), totalWidth, contentHeight, selectedId, highlightLinkedDependenciesOnSelect);
1972
2285
  }
1973
2286
  //#endregion
1974
2287
  //#region src/gantt-chart/vanilla/utils.ts
@@ -1980,6 +2293,9 @@ function buildTaskIndex(tasks) {
1980
2293
  }
1981
2294
  return index;
1982
2295
  }
2296
+ function toIsoDate(date) {
2297
+ return date.toISOString().slice(0, 10);
2298
+ }
1983
2299
  function buildSpecialDayIndex(specialDays) {
1984
2300
  const map = /* @__PURE__ */ new Map();
1985
2301
  for (const specialDay of specialDays) {
@@ -1993,9 +2309,6 @@ function buildSpecialDayIndex(specialDays) {
1993
2309
  }
1994
2310
  return map;
1995
2311
  }
1996
- function toIsoDate(date) {
1997
- return date.toISOString().slice(0, 10);
1998
- }
1999
2312
  function normalizeWeekendDays(days) {
2000
2313
  if (days === void 0) return new Set([0, 6]);
2001
2314
  const normalized = /* @__PURE__ */ new Set();
@@ -2076,10 +2389,23 @@ function computeLeftPaneWidth(options) {
2076
2389
  //#region src/gantt-chart/vanilla/gantt-chart.ts
2077
2390
  const HEADER_H = 52;
2078
2391
  const OVERSCAN = 4;
2392
+ /**
2393
+ * Progressive-enhancement Gantt chart component.
2394
+ * Validates input, builds a DOM tree, and renders a full interactive chart
2395
+ * inside the given container element.
2396
+ *
2397
+ * @example
2398
+ * ```ts
2399
+ * const chart = new GanttChart(document.getElementById('chart')!, input, {
2400
+ * locale: 'de-DE',
2401
+ * theme: 'dark',
2402
+ * });
2403
+ * ```
2404
+ */
2079
2405
  var GanttChart = class {
2080
2406
  #container;
2081
2407
  #opts;
2082
- #input;
2408
+ #input = null;
2083
2409
  #scale;
2084
2410
  #selectedId = null;
2085
2411
  #scrollTop = 0;
@@ -2107,14 +2433,18 @@ var GanttChart = class {
2107
2433
  #cbs;
2108
2434
  #resizeObserver = null;
2109
2435
  #columnResizeCleanup;
2110
- constructor(container, input, opts = {}) {
2436
+ /**
2437
+ * Constructs a new chart, builds the DOM, and wires internal event handling.
2438
+ * Data must be loaded via {@link update} before the chart renders.
2439
+ *
2440
+ * @param container - The host `HTMLElement` the chart will be appended to.
2441
+ * @param opts - Configuration and callback options.
2442
+ */
2443
+ constructor(container, opts = {}) {
2111
2444
  this.#container = container;
2112
- validateLinkRefs(input.tasks, input.links);
2113
- detectCycles(input.tasks, input.links);
2114
- this.#input = input;
2115
2445
  this.#scale = opts.scale ?? "day";
2116
2446
  this.#opts = opts;
2117
- this.#taskIndex = buildTaskIndex(input.tasks);
2447
+ this.#taskIndex = /* @__PURE__ */ new Map();
2118
2448
  this.#locale = resolveChartLocale(opts.locale);
2119
2449
  this.#columns = opts.gridColumns ?? gridColumnDefaults(this.#locale);
2120
2450
  this.#leftPaneDefaultWidth = opts.leftPaneWidth ?? gridNaturalWidth(this.#columns);
@@ -2122,7 +2452,7 @@ var GanttChart = class {
2122
2452
  this.#timelineMinWidth = opts.timelineMinWidth ?? 220;
2123
2453
  this.#weekendDays = normalizeWeekendDays(opts.weekendDays ?? this.#locale.weekendDays);
2124
2454
  this.#specialDaysByDate = buildSpecialDayIndex(opts.specialDays ?? []);
2125
- this.#expandedIds = getInitialExpandedIds(input.tasks);
2455
+ this.#expandedIds = /* @__PURE__ */ new Set();
2126
2456
  this.#cbs = {
2127
2457
  onSelect: (id) => {
2128
2458
  if (this.#selectedId === id) return;
@@ -2142,12 +2472,12 @@ var GanttChart = class {
2142
2472
  },
2143
2473
  onMove: (payload) => {
2144
2474
  const iso = payload.startDate.toISOString().slice(0, 10);
2145
- this.#patchTask(payload.id, { start_date: iso });
2475
+ this.#patchTask(payload.id, { startDate: iso });
2146
2476
  opts.onMove?.(payload);
2147
2477
  this.#scheduleRender();
2148
2478
  },
2149
2479
  onResize: (payload) => {
2150
- this.#patchTask(payload.id, { duration: payload.duration });
2480
+ this.#patchTask(payload.id, { durationHours: payload.durationHours });
2151
2481
  opts.onResize?.(payload);
2152
2482
  this.#scheduleRender();
2153
2483
  },
@@ -2167,21 +2497,44 @@ var GanttChart = class {
2167
2497
  this.#applyTheme();
2168
2498
  this.#applyResponsivePaneStyles();
2169
2499
  this.#setupResizeObserver();
2170
- this.#render();
2171
2500
  }
2501
+ /**
2502
+ * Replaces the full dataset and re-renders.
2503
+ *
2504
+ * @param newInput - The new {@link GanttInput} to apply.
2505
+ * @throws {GanttError} When the instance has been destroyed.
2506
+ */
2172
2507
  update(newInput) {
2173
2508
  this.#assertAlive();
2174
2509
  validateLinkRefs(newInput.tasks, newInput.links);
2175
2510
  detectCycles(newInput.tasks, newInput.links);
2176
2511
  this.#input = newInput;
2177
2512
  this.#taskIndex = buildTaskIndex(newInput.tasks);
2178
- this.#scheduleRender();
2513
+ this.#expandedIds = getInitialExpandedIds(newInput.tasks);
2514
+ if (this.#rafPending && this.#rafId !== null) {
2515
+ cancelAnimationFrame(this.#rafId);
2516
+ this.#rafId = null;
2517
+ this.#rafPending = false;
2518
+ }
2519
+ this.#render();
2179
2520
  }
2521
+ /**
2522
+ * Switches the time scale and re-renders.
2523
+ *
2524
+ * @param scale - The new {@link TimeScale} to display.
2525
+ * @throws {GanttError} When the instance has been destroyed.
2526
+ */
2180
2527
  setScale(scale) {
2181
2528
  this.#assertAlive();
2182
2529
  this.#scale = scale;
2183
2530
  this.#scheduleRender();
2184
2531
  }
2532
+ /**
2533
+ * Programmatically selects or deselects a task.
2534
+ *
2535
+ * @param id - The task ID to select, or `null` to clear the selection.
2536
+ * @throws {GanttError} When the instance has been destroyed.
2537
+ */
2185
2538
  select(id) {
2186
2539
  this.#assertAlive();
2187
2540
  this.#selectedId = id;
@@ -2193,6 +2546,11 @@ var GanttChart = class {
2193
2546
  }
2194
2547
  this.#render();
2195
2548
  }
2549
+ /**
2550
+ * Collapses all expandable groups in the task tree.
2551
+ *
2552
+ * @throws {GanttError} When the instance has been destroyed.
2553
+ */
2196
2554
  collapseAll() {
2197
2555
  this.#assertAlive();
2198
2556
  this.#expandedIds.clear();
@@ -2203,10 +2561,15 @@ var GanttChart = class {
2203
2561
  }
2204
2562
  this.#render();
2205
2563
  }
2564
+ /**
2565
+ * Expands all expandable groups in the task tree.
2566
+ *
2567
+ * @throws {GanttError} When the instance has been destroyed.
2568
+ */
2206
2569
  expandAll() {
2207
2570
  this.#assertAlive();
2208
2571
  this.#expandedIds.clear();
2209
- for (const id of getExpandableTaskIds(this.#input.tasks)) this.#expandedIds.add(id);
2572
+ if (this.#input !== null) for (const id of getExpandableTaskIds(this.#input.tasks)) this.#expandedIds.add(id);
2210
2573
  if (this.#rafPending && this.#rafId !== null) {
2211
2574
  cancelAnimationFrame(this.#rafId);
2212
2575
  this.#rafId = null;
@@ -2214,6 +2577,10 @@ var GanttChart = class {
2214
2577
  }
2215
2578
  this.#render();
2216
2579
  }
2580
+ /**
2581
+ * Removes the chart DOM and internal listeners, rendering the instance
2582
+ * unusable. Subsequent calls to any public method will throw.
2583
+ */
2217
2584
  destroy() {
2218
2585
  if (this.#destroyed) return;
2219
2586
  this.#destroyed = true;
@@ -2229,6 +2596,7 @@ var GanttChart = class {
2229
2596
  clearChildren(this.#container);
2230
2597
  }
2231
2598
  #patchTask(id, patch) {
2599
+ if (this.#input === null) return;
2232
2600
  const index = this.#taskIndex.get(id);
2233
2601
  if (index === void 0) return;
2234
2602
  const target = this.#input.tasks[index];
@@ -2246,7 +2614,7 @@ var GanttChart = class {
2246
2614
  this.#cbs.onTaskEditIntent?.({
2247
2615
  id: payload.id,
2248
2616
  source: "grid",
2249
- trigger: "double_click",
2617
+ trigger: "doubleClick",
2250
2618
  task: payload.task
2251
2619
  });
2252
2620
  return;
@@ -2278,13 +2646,13 @@ var GanttChart = class {
2278
2646
  this.#leftPane.style.maxWidth = `${computedWidth}px`;
2279
2647
  this.#rightPane.style.minWidth = `${this.#timelineMinWidth}px`;
2280
2648
  };
2281
- #computeState() {
2282
- const allRows = flattenTree(buildTaskTree(this.#input.tasks), this.#expandedIds);
2283
- const [vpStart, vpEnd] = this.#opts.viewportStart !== void 0 && this.#opts.viewportEnd !== void 0 ? [this.#opts.viewportStart, this.#opts.viewportEnd] : deriveViewport(allRows, 2);
2649
+ #computeState(input) {
2650
+ const allRows = flattenTree(buildTaskTree(input.tasks), this.#expandedIds);
2651
+ const [vpStart, vpEnd] = this.#opts.viewportStart !== void 0 && this.#opts.viewportEnd !== void 0 ? [this.#opts.viewportStart, this.#opts.viewportEnd] : deriveViewport(allRows, 48);
2284
2652
  const mapper = createPixelMapper(this.#scale, vpStart);
2285
2653
  const totalWidth = Math.ceil(mapper.toX(vpEnd)) + 1;
2286
2654
  const layouts = computeLayout(allRows, mapper);
2287
- const links = routeLinks(this.#input.links, layouts);
2655
+ const links = routeLinks(input.links, layouts);
2288
2656
  const containerH = this.#height - HEADER_H;
2289
2657
  const rowCount = allRows.length;
2290
2658
  const startIndex = Math.max(0, Math.floor(this.#scrollTop / ROW_HEIGHT) - OVERSCAN);
@@ -2292,7 +2660,7 @@ var GanttChart = class {
2292
2660
  const paddingTop = startIndex * ROW_HEIGHT;
2293
2661
  const paddingBottom = Math.max(0, (rowCount - 1 - endIndex) * ROW_HEIGHT);
2294
2662
  return {
2295
- input: this.#input,
2663
+ input,
2296
2664
  scale: this.#scale,
2297
2665
  highlightLinkedDependenciesOnSelect: this.#opts.highlightLinkedDependenciesOnSelect ?? false,
2298
2666
  linkCreationEnabled: this.#opts.linkCreationEnabled ?? false,
@@ -2318,7 +2686,9 @@ var GanttChart = class {
2318
2686
  }
2319
2687
  #render = () => {
2320
2688
  this.#rafPending = false;
2321
- const state = this.#computeState();
2689
+ const input = this.#input;
2690
+ if (input === null) return;
2691
+ const state = this.#computeState(input);
2322
2692
  renderTimeHeader(this.#rightHeader, state);
2323
2693
  renderLeftPane(this.#leftBody, state, {
2324
2694
  onToggle: (id) => {
@@ -2457,6 +2827,6 @@ var GanttChart = class {
2457
2827
  }
2458
2828
  };
2459
2829
  //#endregion
2460
- 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, buildTaskTree, computeLayout, createPixelMapper, deriveViewport, deriveWeekNumbering, deriveWeekStartsOn, deriveWeekendDays, detectCycles, diffDays, flattenTree, formatLabel, formatWeekNumber, gridColumnDefaults, gridNaturalWidth, gridTemplateColumns, isParent, parseDate, parseGanttInput, resolveChartLocale, routeLinks, safeParseGanttInput, validateLinkRefs, visibleColumns };
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 };
2461
2831
 
2462
2832
  //# sourceMappingURL=index.mjs.map