gantt-renderer 0.1.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/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +546 -0
- package/dist/index.d.mts +364 -0
- package/dist/index.mjs +2462 -0
- package/dist/index.mjs.map +1 -0
- package/dist/styles/gantt.css +238 -0
- package/package.json +73 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2462 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
//#region src/gantt-chart/validation/schemas.ts
|
|
3
|
+
const LinkTypeSchema = z.enum([
|
|
4
|
+
"FS",
|
|
5
|
+
"SS",
|
|
6
|
+
"FF",
|
|
7
|
+
"SF"
|
|
8
|
+
]);
|
|
9
|
+
const TaskTypeSchema = z.enum([
|
|
10
|
+
"task",
|
|
11
|
+
"project",
|
|
12
|
+
"milestone"
|
|
13
|
+
]);
|
|
14
|
+
const SpecialDayKindSchema = z.enum(["holiday", "custom"]);
|
|
15
|
+
const SpecialDaySchema = z.object({
|
|
16
|
+
/** ISO date: YYYY-MM-DD */
|
|
17
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD"),
|
|
18
|
+
kind: SpecialDayKindSchema,
|
|
19
|
+
label: z.string().min(1).optional(),
|
|
20
|
+
className: z.string().min(1).optional()
|
|
21
|
+
});
|
|
22
|
+
const TaskSchema = z.object({
|
|
23
|
+
id: z.number().int().positive(),
|
|
24
|
+
text: z.string().min(1),
|
|
25
|
+
/** 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),
|
|
29
|
+
parent: z.number().int().positive().optional(),
|
|
30
|
+
/** 0–1 completion ratio */
|
|
31
|
+
progress: z.number().min(0).max(1).default(0),
|
|
32
|
+
type: TaskTypeSchema.default("task"),
|
|
33
|
+
/** Initial expanded state (only relevant for parent nodes) */
|
|
34
|
+
open: z.boolean().default(true),
|
|
35
|
+
color: z.string().optional()
|
|
36
|
+
});
|
|
37
|
+
const LinkSchema = z.object({
|
|
38
|
+
id: z.number().int().positive(),
|
|
39
|
+
source: z.number().int().positive(),
|
|
40
|
+
target: z.number().int().positive(),
|
|
41
|
+
type: LinkTypeSchema.default("FS")
|
|
42
|
+
});
|
|
43
|
+
const GanttInputSchema = z.object({
|
|
44
|
+
tasks: z.array(TaskSchema).min(1),
|
|
45
|
+
links: z.array(LinkSchema).default([])
|
|
46
|
+
});
|
|
47
|
+
/** Parses raw external data. Throws ZodError on failure. */
|
|
48
|
+
function parseGanttInput(raw) {
|
|
49
|
+
return GanttInputSchema.parse(raw);
|
|
50
|
+
}
|
|
51
|
+
/** Returns null instead of throwing. */
|
|
52
|
+
function safeParseGanttInput(raw) {
|
|
53
|
+
const result = GanttInputSchema.safeParse(raw);
|
|
54
|
+
return result.success ? result.data : null;
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/gantt-chart/errors.ts
|
|
58
|
+
var GanttError = class extends Error {
|
|
59
|
+
code;
|
|
60
|
+
constructor(code, message) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = "GanttError";
|
|
63
|
+
this.code = code;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/gantt-chart/domain/tree.ts
|
|
68
|
+
/**
|
|
69
|
+
* Builds a typed tree from a flat task array.
|
|
70
|
+
* Order of tasks[] is irrelevant — parents need not precede children.
|
|
71
|
+
* Throws if any parent reference is unresolvable.
|
|
72
|
+
*/
|
|
73
|
+
function buildTaskTree(tasks) {
|
|
74
|
+
const map = /* @__PURE__ */ new Map();
|
|
75
|
+
const roots = [];
|
|
76
|
+
for (const task of tasks) map.set(task.id, {
|
|
77
|
+
...task,
|
|
78
|
+
children: [],
|
|
79
|
+
depth: 0
|
|
80
|
+
});
|
|
81
|
+
for (const task of tasks) {
|
|
82
|
+
const node = map.get(task.id);
|
|
83
|
+
if (node === void 0) continue;
|
|
84
|
+
if (task.parent !== void 0) {
|
|
85
|
+
const parent = map.get(task.parent);
|
|
86
|
+
if (parent === void 0) throw new GanttError("PARENT_REFERENCE", `Task id=${task.id} references non-existent parent id=${task.parent}`);
|
|
87
|
+
parent.children.push(node);
|
|
88
|
+
} else roots.push(node);
|
|
89
|
+
}
|
|
90
|
+
(function setDepths(nodes, d) {
|
|
91
|
+
for (const n of nodes) {
|
|
92
|
+
n.depth = d;
|
|
93
|
+
setDepths(n.children, d + 1);
|
|
94
|
+
}
|
|
95
|
+
})(roots, 0);
|
|
96
|
+
return roots;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Flattens a tree into a visible row list.
|
|
100
|
+
* A node's children are included only when its id is in expandedIds.
|
|
101
|
+
*/
|
|
102
|
+
function flattenTree(roots, expandedIds) {
|
|
103
|
+
const rows = [];
|
|
104
|
+
function walk(node) {
|
|
105
|
+
rows.push(node);
|
|
106
|
+
if (node.children.length > 0 && expandedIds.has(node.id)) for (const child of node.children) walk(child);
|
|
107
|
+
}
|
|
108
|
+
for (const root of roots) walk(root);
|
|
109
|
+
return rows;
|
|
110
|
+
}
|
|
111
|
+
/** Returns true when a node has children in the tree. */
|
|
112
|
+
function isParent(node) {
|
|
113
|
+
return node.children.length > 0;
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/gantt-chart/domain/dependencies.ts
|
|
117
|
+
/**
|
|
118
|
+
* 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.
|
|
121
|
+
*/
|
|
122
|
+
function detectCycles(tasks, links) {
|
|
123
|
+
const adj = /* @__PURE__ */ new Map();
|
|
124
|
+
for (const task of tasks) adj.set(task.id, []);
|
|
125
|
+
for (const link of links) {
|
|
126
|
+
const neighbors = adj.get(link.source);
|
|
127
|
+
if (neighbors !== void 0) neighbors.push(link.target);
|
|
128
|
+
}
|
|
129
|
+
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
130
|
+
const color = /* @__PURE__ */ new Map();
|
|
131
|
+
const parent = /* @__PURE__ */ new Map();
|
|
132
|
+
for (const id of adj.keys()) color.set(id, WHITE);
|
|
133
|
+
const dfs = (u) => {
|
|
134
|
+
color.set(u, GRAY);
|
|
135
|
+
for (const v of adj.get(u) ?? []) {
|
|
136
|
+
const vc = color.get(v) ?? WHITE;
|
|
137
|
+
if (vc === GRAY) {
|
|
138
|
+
const path = [v, u];
|
|
139
|
+
let cur = u;
|
|
140
|
+
while (cur !== v) {
|
|
141
|
+
const p = parent.get(cur);
|
|
142
|
+
if (p === void 0) break;
|
|
143
|
+
path.push(p);
|
|
144
|
+
cur = p;
|
|
145
|
+
}
|
|
146
|
+
throw new GanttError("DEPENDENCY_CYCLE", `Circular dependency detected: ${[...path].reverse().join(" -> ")}`);
|
|
147
|
+
}
|
|
148
|
+
if (vc === WHITE) {
|
|
149
|
+
parent.set(v, u);
|
|
150
|
+
dfs(v);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
color.set(u, BLACK);
|
|
154
|
+
};
|
|
155
|
+
for (const id of adj.keys()) if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Returns true when all link source/target ids reference existing tasks.
|
|
159
|
+
* Throws with details on failure.
|
|
160
|
+
*/
|
|
161
|
+
function validateLinkRefs(tasks, links) {
|
|
162
|
+
const ids = new Set(tasks.map((t) => t.id));
|
|
163
|
+
for (const link of links) {
|
|
164
|
+
if (!ids.has(link.source)) throw new GanttError("LINK_REFERENCE", `Link id=${link.id}: source=${link.source} not found`);
|
|
165
|
+
if (!ids.has(link.target)) throw new GanttError("LINK_REFERENCE", `Link id=${link.id}: target=${link.target} not found`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
//#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
|
+
const WEEK_START_REGION = {
|
|
241
|
+
US: 0,
|
|
242
|
+
CA: 0,
|
|
243
|
+
MX: 0,
|
|
244
|
+
JP: 0,
|
|
245
|
+
PH: 0,
|
|
246
|
+
BR: 0,
|
|
247
|
+
CO: 0,
|
|
248
|
+
VE: 0,
|
|
249
|
+
PE: 0,
|
|
250
|
+
EC: 0,
|
|
251
|
+
CL: 0,
|
|
252
|
+
AR: 0,
|
|
253
|
+
UY: 0,
|
|
254
|
+
PY: 0,
|
|
255
|
+
BO: 0,
|
|
256
|
+
GT: 0,
|
|
257
|
+
HN: 0,
|
|
258
|
+
SV: 0,
|
|
259
|
+
NI: 0,
|
|
260
|
+
CR: 0,
|
|
261
|
+
PA: 0,
|
|
262
|
+
DO: 0,
|
|
263
|
+
PR: 0,
|
|
264
|
+
IL: 0,
|
|
265
|
+
SA: 0,
|
|
266
|
+
KW: 0,
|
|
267
|
+
QA: 0,
|
|
268
|
+
BH: 0,
|
|
269
|
+
OM: 0,
|
|
270
|
+
YE: 0,
|
|
271
|
+
MA: 0,
|
|
272
|
+
DZ: 0,
|
|
273
|
+
TN: 0,
|
|
274
|
+
LY: 0,
|
|
275
|
+
EG: 0,
|
|
276
|
+
IQ: 0,
|
|
277
|
+
JO: 0,
|
|
278
|
+
SD: 0,
|
|
279
|
+
SY: 0,
|
|
280
|
+
LB: 0,
|
|
281
|
+
PS: 0,
|
|
282
|
+
AE: 6,
|
|
283
|
+
IR: 6,
|
|
284
|
+
AF: 6,
|
|
285
|
+
DJ: 6,
|
|
286
|
+
SO: 6
|
|
287
|
+
};
|
|
288
|
+
const WEEK_START_LANG = {
|
|
289
|
+
ar: 6,
|
|
290
|
+
fa: 6
|
|
291
|
+
};
|
|
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
|
+
const WEEK_NUMBERING_REGION = {
|
|
311
|
+
US: "us",
|
|
312
|
+
CA: "us",
|
|
313
|
+
MX: "us",
|
|
314
|
+
BR: "us",
|
|
315
|
+
AR: "us",
|
|
316
|
+
CL: "us",
|
|
317
|
+
CO: "us",
|
|
318
|
+
PE: "us",
|
|
319
|
+
JP: "us",
|
|
320
|
+
KR: "us",
|
|
321
|
+
CN: "us",
|
|
322
|
+
TW: "us",
|
|
323
|
+
IN: "us",
|
|
324
|
+
PH: "us",
|
|
325
|
+
IL: "us",
|
|
326
|
+
SA: "us",
|
|
327
|
+
AE: "us",
|
|
328
|
+
IR: "us",
|
|
329
|
+
ZA: "us",
|
|
330
|
+
AU: "us",
|
|
331
|
+
NZ: "us"
|
|
332
|
+
};
|
|
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
|
+
const WEEKEND_REGION = {
|
|
356
|
+
AE: [5, 6],
|
|
357
|
+
AF: [4, 5],
|
|
358
|
+
DZ: [5, 6],
|
|
359
|
+
BH: [5, 6],
|
|
360
|
+
BD: [5, 6],
|
|
361
|
+
EG: [5, 6],
|
|
362
|
+
IQ: [5, 6],
|
|
363
|
+
IL: [5, 6],
|
|
364
|
+
JO: [5, 6],
|
|
365
|
+
KW: [5, 6],
|
|
366
|
+
LY: [5, 6],
|
|
367
|
+
MV: [5, 6],
|
|
368
|
+
MR: [5, 6],
|
|
369
|
+
MA: [5, 6],
|
|
370
|
+
OM: [5, 6],
|
|
371
|
+
PK: [5, 6],
|
|
372
|
+
PS: [5, 6],
|
|
373
|
+
QA: [5, 6],
|
|
374
|
+
SA: [5, 6],
|
|
375
|
+
SD: [5, 6],
|
|
376
|
+
SY: [5, 6],
|
|
377
|
+
TN: [5, 6],
|
|
378
|
+
YE: [5, 6],
|
|
379
|
+
BN: [5, 0],
|
|
380
|
+
IN: [0],
|
|
381
|
+
UG: [0],
|
|
382
|
+
NP: [6],
|
|
383
|
+
IR: [5],
|
|
384
|
+
DJ: [4, 5],
|
|
385
|
+
SO: [5],
|
|
386
|
+
MY: [5, 0]
|
|
387
|
+
};
|
|
388
|
+
/**
|
|
389
|
+
* Formats a week number according to the specified scheme.
|
|
390
|
+
*
|
|
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)`.
|
|
394
|
+
*/
|
|
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);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function isoWeek(date) {
|
|
403
|
+
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
404
|
+
const dayNum = d.getUTCDay() || 7;
|
|
405
|
+
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
|
406
|
+
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
407
|
+
return Math.ceil(((d.getTime() - yearStart.getTime()) / 864e5 + 1) / 7);
|
|
408
|
+
}
|
|
409
|
+
function usWeek(date) {
|
|
410
|
+
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
411
|
+
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
412
|
+
const dayOfYear = Math.floor((d.getTime() - yearStart.getTime()) / 864e5);
|
|
413
|
+
const jan1Dow = yearStart.getUTCDay();
|
|
414
|
+
const weekStartDayOfYear = jan1Dow === 0 ? 0 : -jan1Dow;
|
|
415
|
+
if (dayOfYear < weekStartDayOfYear) return 0;
|
|
416
|
+
return Math.floor((dayOfYear - weekStartDayOfYear) / 7) + 1;
|
|
417
|
+
}
|
|
418
|
+
function simpleWeek(date) {
|
|
419
|
+
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
420
|
+
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
421
|
+
const dayOfYear = Math.floor((d.getTime() - yearStart.getTime()) / 864e5);
|
|
422
|
+
return Math.ceil((dayOfYear + 1) / 7);
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Formats a label template by replacing `{0}` with the given argument.
|
|
426
|
+
*/
|
|
427
|
+
function formatLabel(template, arg) {
|
|
428
|
+
return template.replaceAll("{0}", arg);
|
|
429
|
+
}
|
|
430
|
+
//#endregion
|
|
431
|
+
//#region src/gantt-chart/domain/dateMath.ts
|
|
432
|
+
/** Parses YYYY-MM-DD → UTC midnight Date. Throws on invalid input. */
|
|
433
|
+
function parseDate(dateStr) {
|
|
434
|
+
const d = /* @__PURE__ */ new Date(`${dateStr}T00:00:00.000Z`);
|
|
435
|
+
if (isNaN(d.getTime())) throw new Error(`Invalid date: "${dateStr}"`);
|
|
436
|
+
return d;
|
|
437
|
+
}
|
|
438
|
+
/** Returns date + n days (exact ms arithmetic). */
|
|
439
|
+
function addDays(date, days) {
|
|
440
|
+
return new Date(date.getTime() + days * 864e5);
|
|
441
|
+
}
|
|
442
|
+
/** Difference in days (float). Positive when b > a. */
|
|
443
|
+
function diffDays(a, b) {
|
|
444
|
+
return (b.getTime() - a.getTime()) / 864e5;
|
|
445
|
+
}
|
|
446
|
+
/** UTC start-of-day. */
|
|
447
|
+
function startOfDay(date) {
|
|
448
|
+
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Formats a Date for the time-header label given the active scale.
|
|
452
|
+
*/
|
|
453
|
+
function formatHeaderLabel(date, scale, locale) {
|
|
454
|
+
const { code, weekNumbering: weekNumScheme = "iso" } = locale;
|
|
455
|
+
switch (scale) {
|
|
456
|
+
case "hour": return `${String(date.getUTCHours()).padStart(2, "0")}:00`;
|
|
457
|
+
case "day": {
|
|
458
|
+
const day = date.toLocaleDateString(code, {
|
|
459
|
+
weekday: "short",
|
|
460
|
+
timeZone: "UTC"
|
|
461
|
+
});
|
|
462
|
+
return `${date.getUTCDate()} ${day}`;
|
|
463
|
+
}
|
|
464
|
+
case "week": return `W${formatWeekNumber(date, weekNumScheme)}`;
|
|
465
|
+
case "month": return date.toLocaleDateString(code, {
|
|
466
|
+
month: "short",
|
|
467
|
+
year: "numeric",
|
|
468
|
+
timeZone: "UTC"
|
|
469
|
+
});
|
|
470
|
+
case "quarter": return `${resolveQuarterLabel(locale)}${Math.floor(date.getUTCMonth() / 3) + 1} ${date.getUTCFullYear()}`;
|
|
471
|
+
case "year": return `${date.getUTCFullYear()}`;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Returns the upper-level (month/year) label for a given scale column.
|
|
476
|
+
* Used in the top header row.
|
|
477
|
+
*/
|
|
478
|
+
function formatUpperLabel(date, scale, locale) {
|
|
479
|
+
const { code } = locale;
|
|
480
|
+
switch (scale) {
|
|
481
|
+
case "hour": return date.toLocaleDateString(code, {
|
|
482
|
+
month: "long",
|
|
483
|
+
day: "numeric",
|
|
484
|
+
year: "numeric",
|
|
485
|
+
timeZone: "UTC"
|
|
486
|
+
});
|
|
487
|
+
case "day":
|
|
488
|
+
case "week": return date.toLocaleDateString(code, {
|
|
489
|
+
month: "long",
|
|
490
|
+
year: "numeric",
|
|
491
|
+
timeZone: "UTC"
|
|
492
|
+
});
|
|
493
|
+
case "month": return `${date.getUTCFullYear()}`;
|
|
494
|
+
case "quarter": return `${date.getUTCFullYear()}`;
|
|
495
|
+
case "year": return `${date.getUTCFullYear()}`;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
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. */
|
|
503
|
+
function formatDisplayDate(dateStr, locale) {
|
|
504
|
+
return parseDate(dateStr).toLocaleDateString(locale.code, {
|
|
505
|
+
year: "numeric",
|
|
506
|
+
month: "2-digit",
|
|
507
|
+
day: "2-digit",
|
|
508
|
+
timeZone: "UTC"
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
//#endregion
|
|
512
|
+
//#region src/gantt-chart/timeline/scale.ts
|
|
513
|
+
const H = 36e5;
|
|
514
|
+
const D = 864e5;
|
|
515
|
+
const SCALE_CONFIGS = {
|
|
516
|
+
hour: {
|
|
517
|
+
columnWidth: 60,
|
|
518
|
+
msPerColumn: H,
|
|
519
|
+
headerFormat: "hour"
|
|
520
|
+
},
|
|
521
|
+
day: {
|
|
522
|
+
columnWidth: 72,
|
|
523
|
+
msPerColumn: D,
|
|
524
|
+
headerFormat: "day"
|
|
525
|
+
},
|
|
526
|
+
week: {
|
|
527
|
+
columnWidth: 120,
|
|
528
|
+
msPerColumn: 7 * D,
|
|
529
|
+
headerFormat: "week"
|
|
530
|
+
},
|
|
531
|
+
month: {
|
|
532
|
+
columnWidth: 160,
|
|
533
|
+
msPerColumn: 30 * D,
|
|
534
|
+
headerFormat: "month"
|
|
535
|
+
},
|
|
536
|
+
quarter: {
|
|
537
|
+
columnWidth: 220,
|
|
538
|
+
msPerColumn: 91 * D,
|
|
539
|
+
headerFormat: "quarter"
|
|
540
|
+
},
|
|
541
|
+
year: {
|
|
542
|
+
columnWidth: 280,
|
|
543
|
+
msPerColumn: 365 * D,
|
|
544
|
+
headerFormat: "year"
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
/**
|
|
548
|
+
* Snaps a date to the column boundary for the provided scale.
|
|
549
|
+
* 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.
|
|
552
|
+
*/
|
|
553
|
+
function snapToScaleBoundary(date, scale, weekStartsOn = 1) {
|
|
554
|
+
switch (scale) {
|
|
555
|
+
case "hour": return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours()));
|
|
556
|
+
case "day": return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
557
|
+
case "week": {
|
|
558
|
+
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
559
|
+
const offset = ((d.getUTCDay() - weekStartsOn) % 7 + 7) % 7;
|
|
560
|
+
d.setUTCDate(d.getUTCDate() - offset);
|
|
561
|
+
return d;
|
|
562
|
+
}
|
|
563
|
+
case "month": return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
|
|
564
|
+
case "quarter": {
|
|
565
|
+
const month = date.getUTCMonth();
|
|
566
|
+
const quarterStartMonth = Math.floor(month / 3) * 3;
|
|
567
|
+
return new Date(Date.UTC(date.getUTCFullYear(), quarterStartMonth, 1));
|
|
568
|
+
}
|
|
569
|
+
case "year": return new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Returns the next column boundary from a boundary-aligned date.
|
|
574
|
+
* Month/quarter/year use true calendar stepping (not fixed-day approximations).
|
|
575
|
+
*/
|
|
576
|
+
function nextScaleBoundary(date, scale) {
|
|
577
|
+
switch (scale) {
|
|
578
|
+
case "hour": return new Date(date.getTime() + H);
|
|
579
|
+
case "day": return new Date(date.getTime() + D);
|
|
580
|
+
case "week": return new Date(date.getTime() + 7 * D);
|
|
581
|
+
case "month": return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1));
|
|
582
|
+
case "quarter": return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 3, 1));
|
|
583
|
+
case "year": return new Date(Date.UTC(date.getUTCFullYear() + 1, 0, 1));
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
//#endregion
|
|
587
|
+
//#region src/gantt-chart/timeline/pixelMapper.ts
|
|
588
|
+
/**
|
|
589
|
+
* Creates a stateless pixel mapper for the given scale and viewport start.
|
|
590
|
+
* All conversions are O(1) arithmetic — safe to call in tight loops.
|
|
591
|
+
*/
|
|
592
|
+
function createPixelMapper(scale, viewportStart) {
|
|
593
|
+
const { columnWidth, msPerColumn } = SCALE_CONFIGS[scale];
|
|
594
|
+
const originMs = viewportStart.getTime();
|
|
595
|
+
const pxPerMs = columnWidth / msPerColumn;
|
|
596
|
+
const msPerPx = msPerColumn / columnWidth;
|
|
597
|
+
const msPerDay = 864e5;
|
|
598
|
+
return {
|
|
599
|
+
originMs,
|
|
600
|
+
columnWidth,
|
|
601
|
+
toX(date) {
|
|
602
|
+
return (date.getTime() - originMs) * pxPerMs;
|
|
603
|
+
},
|
|
604
|
+
toDate(x) {
|
|
605
|
+
return new Date(originMs + x * msPerPx);
|
|
606
|
+
},
|
|
607
|
+
durationToWidth(days) {
|
|
608
|
+
return days * msPerDay * pxPerMs;
|
|
609
|
+
},
|
|
610
|
+
widthToDuration(px) {
|
|
611
|
+
return px * msPerPx / msPerDay;
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
//#endregion
|
|
616
|
+
//#region src/gantt-chart/timeline/layoutEngine.ts
|
|
617
|
+
const DENSITY = {
|
|
618
|
+
rowHeight: 44,
|
|
619
|
+
barHeight: 28,
|
|
620
|
+
milestoneSize: 20
|
|
621
|
+
};
|
|
622
|
+
const ROW_HEIGHT = DENSITY.rowHeight;
|
|
623
|
+
const BAR_HEIGHT = DENSITY.barHeight;
|
|
624
|
+
const BAR_Y_OFFSET = (ROW_HEIGHT - BAR_HEIGHT) / 2;
|
|
625
|
+
const MILESTONE_SIZE = DENSITY.milestoneSize;
|
|
626
|
+
/** Half-width of a milestone diamond */
|
|
627
|
+
const MILESTONE_HALF = MILESTONE_SIZE / 2;
|
|
628
|
+
/**
|
|
629
|
+
* Computes pixel-space layout for all visible task rows.
|
|
630
|
+
* Returns a map keyed by task id for O(1) lookup during link routing.
|
|
631
|
+
*/
|
|
632
|
+
function computeLayout(rows, mapper) {
|
|
633
|
+
const result = /* @__PURE__ */ new Map();
|
|
634
|
+
for (let i = 0; i < rows.length; i++) {
|
|
635
|
+
const task = rows[i];
|
|
636
|
+
if (task === void 0) continue;
|
|
637
|
+
const start = parseDate(task.start_date);
|
|
638
|
+
const x = mapper.toX(start);
|
|
639
|
+
const y = i * ROW_HEIGHT + BAR_Y_OFFSET;
|
|
640
|
+
const centerY = i * ROW_HEIGHT + ROW_HEIGHT / 2;
|
|
641
|
+
const type = task.type ?? "task";
|
|
642
|
+
if (type === "milestone") {
|
|
643
|
+
result.set(task.id, {
|
|
644
|
+
taskId: task.id,
|
|
645
|
+
x,
|
|
646
|
+
y,
|
|
647
|
+
width: 0,
|
|
648
|
+
height: BAR_HEIGHT,
|
|
649
|
+
progressWidth: 0,
|
|
650
|
+
type: "milestone",
|
|
651
|
+
rowIndex: i,
|
|
652
|
+
centerX: x,
|
|
653
|
+
centerY
|
|
654
|
+
});
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
const width = Math.max(mapper.durationToWidth(task.duration), 4);
|
|
658
|
+
const progressWidth = width * Math.min(1, Math.max(0, task.progress ?? 0));
|
|
659
|
+
result.set(task.id, {
|
|
660
|
+
taskId: task.id,
|
|
661
|
+
x,
|
|
662
|
+
y,
|
|
663
|
+
width,
|
|
664
|
+
height: BAR_HEIGHT,
|
|
665
|
+
progressWidth,
|
|
666
|
+
type,
|
|
667
|
+
rowIndex: i,
|
|
668
|
+
centerX: x + width / 2,
|
|
669
|
+
centerY
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
return result;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Computes the total pixel height of all rows.
|
|
676
|
+
*/
|
|
677
|
+
function totalContentHeight(rowCount) {
|
|
678
|
+
return rowCount * ROW_HEIGHT;
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Derives viewport bounds from task data with padding.
|
|
682
|
+
* Returns [start, end] as UTC midnight dates.
|
|
683
|
+
*/
|
|
684
|
+
function deriveViewport(tasks, paddingDays = 2) {
|
|
685
|
+
if (tasks.length === 0) {
|
|
686
|
+
const now = /* @__PURE__ */ new Date();
|
|
687
|
+
return [now, addDays(now, 30)];
|
|
688
|
+
}
|
|
689
|
+
let minMs = Infinity;
|
|
690
|
+
let maxMs = -Infinity;
|
|
691
|
+
for (const task of tasks) {
|
|
692
|
+
const start = parseDate(task.start_date);
|
|
693
|
+
const end = addDays(start, task.duration);
|
|
694
|
+
if (start.getTime() < minMs) minMs = start.getTime();
|
|
695
|
+
if (end.getTime() > maxMs) maxMs = end.getTime();
|
|
696
|
+
}
|
|
697
|
+
return [addDays(new Date(minMs), -paddingDays), addDays(new Date(maxMs), paddingDays)];
|
|
698
|
+
}
|
|
699
|
+
//#endregion
|
|
700
|
+
//#region src/gantt-chart/rendering/linkRouter.ts
|
|
701
|
+
const TURN_MARGIN = 12;
|
|
702
|
+
/**
|
|
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
|
+
* Produces the vertex list for an orthogonal connector between src and tgt.
|
|
722
|
+
*
|
|
723
|
+
* Link semantics:
|
|
724
|
+
* FS = source.finish → target.start (most common)
|
|
725
|
+
* SS = source.start → target.start
|
|
726
|
+
* FF = source.finish → target.finish
|
|
727
|
+
* SF = source.start → target.finish
|
|
728
|
+
*/
|
|
729
|
+
function route(type, src, tgt) {
|
|
730
|
+
let sx, tx;
|
|
731
|
+
const sy = src.centerY;
|
|
732
|
+
const ty = tgt.centerY;
|
|
733
|
+
switch (type) {
|
|
734
|
+
case "FS":
|
|
735
|
+
sx = src.type === "milestone" ? src.x + MILESTONE_HALF : src.x + src.width;
|
|
736
|
+
tx = tgt.type === "milestone" ? tgt.x - MILESTONE_HALF : tgt.x;
|
|
737
|
+
break;
|
|
738
|
+
case "SS":
|
|
739
|
+
sx = src.type === "milestone" ? src.x - MILESTONE_HALF : src.x;
|
|
740
|
+
tx = tgt.type === "milestone" ? tgt.x - MILESTONE_HALF : tgt.x;
|
|
741
|
+
break;
|
|
742
|
+
case "FF":
|
|
743
|
+
sx = src.type === "milestone" ? src.x + MILESTONE_HALF : src.x + src.width;
|
|
744
|
+
tx = tgt.type === "milestone" ? tgt.x + MILESTONE_HALF : tgt.x + tgt.width;
|
|
745
|
+
break;
|
|
746
|
+
case "SF":
|
|
747
|
+
sx = src.type === "milestone" ? src.x - MILESTONE_HALF : src.x;
|
|
748
|
+
tx = tgt.type === "milestone" ? tgt.x + MILESTONE_HALF : tgt.x + tgt.width;
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
if (Math.abs(sy - ty) < 1) return [{
|
|
752
|
+
x: sx,
|
|
753
|
+
y: sy
|
|
754
|
+
}, {
|
|
755
|
+
x: tx,
|
|
756
|
+
y: ty
|
|
757
|
+
}];
|
|
758
|
+
if (sx <= tx) {
|
|
759
|
+
const midX = sx + Math.max(TURN_MARGIN, (tx - sx) / 2);
|
|
760
|
+
return [
|
|
761
|
+
{
|
|
762
|
+
x: sx,
|
|
763
|
+
y: sy
|
|
764
|
+
},
|
|
765
|
+
{
|
|
766
|
+
x: midX,
|
|
767
|
+
y: sy
|
|
768
|
+
},
|
|
769
|
+
{
|
|
770
|
+
x: midX,
|
|
771
|
+
y: ty
|
|
772
|
+
},
|
|
773
|
+
{
|
|
774
|
+
x: tx,
|
|
775
|
+
y: ty
|
|
776
|
+
}
|
|
777
|
+
];
|
|
778
|
+
}
|
|
779
|
+
const loopX = tx - TURN_MARGIN;
|
|
780
|
+
return [
|
|
781
|
+
{
|
|
782
|
+
x: sx,
|
|
783
|
+
y: sy
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
x: sx + TURN_MARGIN,
|
|
787
|
+
y: sy
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
x: sx + TURN_MARGIN,
|
|
791
|
+
y: (sy + ty) / 2
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
x: loopX,
|
|
795
|
+
y: (sy + ty) / 2
|
|
796
|
+
},
|
|
797
|
+
{
|
|
798
|
+
x: loopX,
|
|
799
|
+
y: ty
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
x: tx,
|
|
803
|
+
y: ty
|
|
804
|
+
}
|
|
805
|
+
];
|
|
806
|
+
}
|
|
807
|
+
//#endregion
|
|
808
|
+
//#region src/gantt-chart/vanilla/dom/helpers.ts
|
|
809
|
+
function el(tag, props, ns) {
|
|
810
|
+
const elem = ns ? document.createElementNS(ns, tag) : document.createElement(tag);
|
|
811
|
+
if (props !== void 0) for (const [k, v] of Object.entries(props)) if (k === "style" && typeof v === "object" && v !== null) css(elem, v);
|
|
812
|
+
else if (k in elem) elem[k] = v;
|
|
813
|
+
else elem.setAttribute(k, String(v));
|
|
814
|
+
return elem;
|
|
815
|
+
}
|
|
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. */
|
|
821
|
+
function clearChildren(elem) {
|
|
822
|
+
while (elem.firstChild !== null) elem.removeChild(elem.firstChild);
|
|
823
|
+
}
|
|
824
|
+
/** Appends all nodes from an array/fragment into parent in one pass. */
|
|
825
|
+
function appendAll(parent, children) {
|
|
826
|
+
const frag = document.createDocumentFragment();
|
|
827
|
+
for (const c of children) frag.appendChild(c);
|
|
828
|
+
parent.append(frag);
|
|
829
|
+
}
|
|
830
|
+
/** Sets multiple SVG attributes in one call. */
|
|
831
|
+
function setAttrs(elem, attrs) {
|
|
832
|
+
for (const [k, v] of Object.entries(attrs)) elem.setAttribute(k, String(v));
|
|
833
|
+
}
|
|
834
|
+
//#endregion
|
|
835
|
+
//#region src/gantt-chart/vanilla/dom/timeHeader.ts
|
|
836
|
+
/**
|
|
837
|
+
* Fully replaces the content of `container` with two header rows.
|
|
838
|
+
* Called on scale change or viewport change only — not on scroll.
|
|
839
|
+
*/
|
|
840
|
+
function renderTimeHeader(container, state) {
|
|
841
|
+
const { scale, viewportStart, viewportEnd, mapper, totalWidth, locale } = state;
|
|
842
|
+
const weekStartsOn = locale.weekStartsOn ?? 1;
|
|
843
|
+
const upperCells = [];
|
|
844
|
+
const lowerCells = [];
|
|
845
|
+
let cur = snapToScaleBoundary(viewportStart, scale, weekStartsOn);
|
|
846
|
+
let prevUpperLabel = "";
|
|
847
|
+
let upperStart = 0;
|
|
848
|
+
let upperWidth = 0;
|
|
849
|
+
while (cur < viewportEnd) {
|
|
850
|
+
const next = nextScaleBoundary(cur, scale);
|
|
851
|
+
const x = mapper.toX(cur);
|
|
852
|
+
const w = mapper.toX(next) - x;
|
|
853
|
+
lowerCells.push({
|
|
854
|
+
label: formatHeaderLabel(cur, scale, locale),
|
|
855
|
+
x,
|
|
856
|
+
width: w
|
|
857
|
+
});
|
|
858
|
+
const uLabel = formatUpperLabel(cur, scale, locale);
|
|
859
|
+
if (uLabel !== prevUpperLabel) {
|
|
860
|
+
if (prevUpperLabel !== "") upperCells.push({
|
|
861
|
+
label: prevUpperLabel,
|
|
862
|
+
x: upperStart,
|
|
863
|
+
width: upperWidth
|
|
864
|
+
});
|
|
865
|
+
prevUpperLabel = uLabel;
|
|
866
|
+
upperStart = x;
|
|
867
|
+
upperWidth = w;
|
|
868
|
+
} else upperWidth += w;
|
|
869
|
+
cur = next;
|
|
870
|
+
}
|
|
871
|
+
if (prevUpperLabel !== "") upperCells.push({
|
|
872
|
+
label: prevUpperLabel,
|
|
873
|
+
x: upperStart,
|
|
874
|
+
width: upperWidth
|
|
875
|
+
});
|
|
876
|
+
const upperRow = el("div");
|
|
877
|
+
css_(upperRow, {
|
|
878
|
+
position: "relative",
|
|
879
|
+
height: "24px",
|
|
880
|
+
width: `${totalWidth}px`,
|
|
881
|
+
background: "var(--gantt-header-bg)",
|
|
882
|
+
borderBottom: "1px solid var(--gantt-border)"
|
|
883
|
+
});
|
|
884
|
+
const upperNodes = upperCells.map((cell) => {
|
|
885
|
+
const d = el("div");
|
|
886
|
+
css_(d, {
|
|
887
|
+
position: "absolute",
|
|
888
|
+
left: `${cell.x}px`,
|
|
889
|
+
width: `${cell.width}px`,
|
|
890
|
+
height: "100%",
|
|
891
|
+
borderRight: "1px solid var(--gantt-border)",
|
|
892
|
+
display: "flex",
|
|
893
|
+
alignItems: "center",
|
|
894
|
+
paddingLeft: "8px",
|
|
895
|
+
fontSize: "var(--gantt-font-size-xs)",
|
|
896
|
+
fontWeight: "var(--gantt-font-weight-bold)",
|
|
897
|
+
color: "var(--gantt-text)",
|
|
898
|
+
overflow: "hidden",
|
|
899
|
+
whiteSpace: "nowrap",
|
|
900
|
+
letterSpacing: "var(--gantt-letter-spacing-tight)",
|
|
901
|
+
textTransform: "uppercase"
|
|
902
|
+
});
|
|
903
|
+
d.textContent = cell.label;
|
|
904
|
+
return d;
|
|
905
|
+
});
|
|
906
|
+
const lowerRow = el("div");
|
|
907
|
+
css_(lowerRow, {
|
|
908
|
+
position: "relative",
|
|
909
|
+
height: "28px",
|
|
910
|
+
width: `${totalWidth}px`,
|
|
911
|
+
background: "var(--gantt-header-bg)",
|
|
912
|
+
borderBottom: "1px solid var(--gantt-border)"
|
|
913
|
+
});
|
|
914
|
+
const lowerNodes = lowerCells.map((cell) => {
|
|
915
|
+
const d = el("div");
|
|
916
|
+
css_(d, {
|
|
917
|
+
position: "absolute",
|
|
918
|
+
left: `${cell.x}px`,
|
|
919
|
+
width: `${cell.width}px`,
|
|
920
|
+
height: "100%",
|
|
921
|
+
borderRight: "1px solid var(--gantt-border)",
|
|
922
|
+
display: "flex",
|
|
923
|
+
alignItems: "center",
|
|
924
|
+
justifyContent: "center",
|
|
925
|
+
fontSize: "var(--gantt-font-size-xs)",
|
|
926
|
+
color: "var(--gantt-text-secondary)",
|
|
927
|
+
overflow: "hidden",
|
|
928
|
+
whiteSpace: "nowrap"
|
|
929
|
+
});
|
|
930
|
+
d.textContent = cell.label;
|
|
931
|
+
return d;
|
|
932
|
+
});
|
|
933
|
+
appendAll(upperRow, upperNodes);
|
|
934
|
+
appendAll(lowerRow, lowerNodes);
|
|
935
|
+
clearChildren(container);
|
|
936
|
+
container.append(upperRow);
|
|
937
|
+
container.append(lowerRow);
|
|
938
|
+
}
|
|
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
|
+
//#endregion
|
|
944
|
+
//#region src/gantt-chart/vanilla/dom/gridColumns.ts
|
|
945
|
+
const DEFAULT_GRID_COLUMNS = [
|
|
946
|
+
{
|
|
947
|
+
id: "name",
|
|
948
|
+
header: "Task name",
|
|
949
|
+
width: "1fr"
|
|
950
|
+
},
|
|
951
|
+
{
|
|
952
|
+
id: "start_date",
|
|
953
|
+
header: "Start time",
|
|
954
|
+
width: "90px",
|
|
955
|
+
field: "start_date",
|
|
956
|
+
format: (value, _task, _row, locale) => formatDisplayDate(String(value), locale)
|
|
957
|
+
},
|
|
958
|
+
{
|
|
959
|
+
id: "duration",
|
|
960
|
+
header: "Duration",
|
|
961
|
+
width: "68px",
|
|
962
|
+
field: "duration",
|
|
963
|
+
format: (value) => value > 0 ? String(value) : "—"
|
|
964
|
+
},
|
|
965
|
+
{
|
|
966
|
+
id: "actions",
|
|
967
|
+
header: "",
|
|
968
|
+
width: "28px"
|
|
969
|
+
}
|
|
970
|
+
];
|
|
971
|
+
/**
|
|
972
|
+
* Returns a localized default grid column schema.
|
|
973
|
+
* Column headers use locale label overrides with EN_US_LABELS fallback.
|
|
974
|
+
*/
|
|
975
|
+
function gridColumnDefaults(locale) {
|
|
976
|
+
return [
|
|
977
|
+
{
|
|
978
|
+
id: "name",
|
|
979
|
+
header: locale.labels?.column_task_name ?? EN_US_LABELS.column_task_name,
|
|
980
|
+
width: "1fr"
|
|
981
|
+
},
|
|
982
|
+
{
|
|
983
|
+
id: "start_date",
|
|
984
|
+
header: locale.labels?.column_start_time ?? EN_US_LABELS.column_start_time,
|
|
985
|
+
width: "90px",
|
|
986
|
+
field: "start_date",
|
|
987
|
+
format: (value, _task, _row, loc) => formatDisplayDate(String(value), loc)
|
|
988
|
+
},
|
|
989
|
+
{
|
|
990
|
+
id: "duration",
|
|
991
|
+
header: locale.labels?.column_duration ?? EN_US_LABELS.column_duration,
|
|
992
|
+
width: "68px",
|
|
993
|
+
field: "duration",
|
|
994
|
+
format: (value) => value > 0 ? String(value) : "—"
|
|
995
|
+
},
|
|
996
|
+
{
|
|
997
|
+
id: "actions",
|
|
998
|
+
header: "",
|
|
999
|
+
width: "28px"
|
|
1000
|
+
}
|
|
1001
|
+
];
|
|
1002
|
+
}
|
|
1003
|
+
function gridTemplateColumns(columns) {
|
|
1004
|
+
return columns.filter((c) => c.visible !== false).map((c) => c.width).join(" ");
|
|
1005
|
+
}
|
|
1006
|
+
function visibleColumns(columns) {
|
|
1007
|
+
return columns.filter((c) => c.visible !== false);
|
|
1008
|
+
}
|
|
1009
|
+
const GRID_COLUMN_FR_MIN_WIDTH = 120;
|
|
1010
|
+
const PX_RE = /^(\d+(?:\.\d+)?)px$/;
|
|
1011
|
+
const FR_RE = /^(\d+(?:\.\d+)?)fr$/;
|
|
1012
|
+
function parseColumnMinWidth(width) {
|
|
1013
|
+
const trimmed = width.trim();
|
|
1014
|
+
const pxMatch = PX_RE.exec(trimmed);
|
|
1015
|
+
if (pxMatch) return parseFloat(pxMatch[1] ?? "0");
|
|
1016
|
+
const frMatch = FR_RE.exec(trimmed);
|
|
1017
|
+
if (frMatch) return parseFloat(frMatch[1] ?? "0") * 120;
|
|
1018
|
+
return 0;
|
|
1019
|
+
}
|
|
1020
|
+
function gridNaturalWidth(columns) {
|
|
1021
|
+
let total = 0;
|
|
1022
|
+
for (const col of visibleColumns(columns)) total += parseColumnMinWidth(col.width);
|
|
1023
|
+
return total;
|
|
1024
|
+
}
|
|
1025
|
+
//#endregion
|
|
1026
|
+
//#region src/gantt-chart/vanilla/dom/leftPane.ts
|
|
1027
|
+
const INDENT = 16;
|
|
1028
|
+
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
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
function buildTreeNameCell(row, expandedIds, cbs) {
|
|
1090
|
+
const hasChildren = isParent(row);
|
|
1091
|
+
const expanded = expandedIds.has(row.id);
|
|
1092
|
+
const cell = el("div");
|
|
1093
|
+
css(cell, {
|
|
1094
|
+
display: "flex",
|
|
1095
|
+
alignItems: "center",
|
|
1096
|
+
paddingLeft: `${row.depth * INDENT}px`,
|
|
1097
|
+
gap: "4px",
|
|
1098
|
+
overflow: "hidden"
|
|
1099
|
+
});
|
|
1100
|
+
if (hasChildren) {
|
|
1101
|
+
const btn = el("button");
|
|
1102
|
+
btn.className = "gantt-toggle";
|
|
1103
|
+
btn.textContent = expanded ? "▾" : "▸";
|
|
1104
|
+
css(btn, {
|
|
1105
|
+
width: "16px",
|
|
1106
|
+
height: "16px",
|
|
1107
|
+
display: "flex",
|
|
1108
|
+
alignItems: "center",
|
|
1109
|
+
justifyContent: "center",
|
|
1110
|
+
background: "none",
|
|
1111
|
+
border: "none",
|
|
1112
|
+
cursor: "pointer",
|
|
1113
|
+
color: "var(--gantt-text-secondary)",
|
|
1114
|
+
padding: "0",
|
|
1115
|
+
flexShrink: "0"
|
|
1116
|
+
});
|
|
1117
|
+
btn.addEventListener("click", (e) => {
|
|
1118
|
+
e.stopPropagation();
|
|
1119
|
+
cbs.onToggle(row.id);
|
|
1120
|
+
});
|
|
1121
|
+
cell.append(btn);
|
|
1122
|
+
} else {
|
|
1123
|
+
const spacer = el("span");
|
|
1124
|
+
spacer.style.width = "16px";
|
|
1125
|
+
spacer.style.flexShrink = "0";
|
|
1126
|
+
cell.append(spacer);
|
|
1127
|
+
}
|
|
1128
|
+
const label = el("span");
|
|
1129
|
+
css(label, {
|
|
1130
|
+
fontSize: "var(--gantt-font-size-md)",
|
|
1131
|
+
fontWeight: row.type === "project" ? "var(--gantt-font-weight-bold)" : "var(--gantt-font-weight-normal)",
|
|
1132
|
+
color: "var(--gantt-text)",
|
|
1133
|
+
overflow: "hidden",
|
|
1134
|
+
textOverflow: "ellipsis",
|
|
1135
|
+
whiteSpace: "nowrap"
|
|
1136
|
+
});
|
|
1137
|
+
label.textContent = row.text;
|
|
1138
|
+
cell.append(label);
|
|
1139
|
+
return cell;
|
|
1140
|
+
}
|
|
1141
|
+
function buildDataCell(row, column, locale) {
|
|
1142
|
+
const cell = el("span");
|
|
1143
|
+
const styles = {
|
|
1144
|
+
fontSize: "var(--gantt-font-size-sm)",
|
|
1145
|
+
color: "var(--gantt-text-secondary)",
|
|
1146
|
+
paddingRight: "8px",
|
|
1147
|
+
overflow: "hidden",
|
|
1148
|
+
textOverflow: "ellipsis",
|
|
1149
|
+
whiteSpace: "nowrap"
|
|
1150
|
+
};
|
|
1151
|
+
if (column.align !== void 0) styles.textAlign = column.align;
|
|
1152
|
+
css(cell, styles);
|
|
1153
|
+
const task = toTask$1(row);
|
|
1154
|
+
if (column.field !== void 0) {
|
|
1155
|
+
const rawValue = task[column.field];
|
|
1156
|
+
if (column.format !== void 0) cell.textContent = column.format(rawValue, task, row, locale);
|
|
1157
|
+
else cell.textContent = rawValue !== null && rawValue !== void 0 ? String(rawValue) : "";
|
|
1158
|
+
}
|
|
1159
|
+
return cell;
|
|
1160
|
+
}
|
|
1161
|
+
function buildAddButton(row, cbs, locale) {
|
|
1162
|
+
const btn = el("button");
|
|
1163
|
+
btn.className = "gantt-add-btn";
|
|
1164
|
+
btn.textContent = "+";
|
|
1165
|
+
btn.title = locale.labels?.add_subtask_title ?? EN_US_LABELS.add_subtask_title;
|
|
1166
|
+
css(btn, {
|
|
1167
|
+
background: "none",
|
|
1168
|
+
border: "none",
|
|
1169
|
+
cursor: "pointer",
|
|
1170
|
+
color: "var(--gantt-text-secondary)",
|
|
1171
|
+
fontSize: "var(--gantt-font-size-lg)",
|
|
1172
|
+
lineHeight: "1"
|
|
1173
|
+
});
|
|
1174
|
+
btn.addEventListener("click", (event) => {
|
|
1175
|
+
event.stopPropagation();
|
|
1176
|
+
cbs.onAdd(row.id);
|
|
1177
|
+
});
|
|
1178
|
+
return btn;
|
|
1179
|
+
}
|
|
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
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
/** Builds the header row for the left pane. */
|
|
1194
|
+
function buildLeftPaneHeader(columns) {
|
|
1195
|
+
const header = el("div");
|
|
1196
|
+
css(header, {
|
|
1197
|
+
display: "grid",
|
|
1198
|
+
gridTemplateColumns: gridTemplateColumns(columns),
|
|
1199
|
+
height: "52px",
|
|
1200
|
+
background: "var(--gantt-header-bg)",
|
|
1201
|
+
borderBottom: "1px solid var(--gantt-border)",
|
|
1202
|
+
paddingLeft: "8px",
|
|
1203
|
+
alignItems: "flex-end",
|
|
1204
|
+
paddingBottom: "4px",
|
|
1205
|
+
boxSizing: "border-box"
|
|
1206
|
+
});
|
|
1207
|
+
const visible = visibleColumns(columns);
|
|
1208
|
+
for (let i = 0; i < visible.length; i++) {
|
|
1209
|
+
const column = visible[i];
|
|
1210
|
+
if (column === void 0) continue;
|
|
1211
|
+
const wrapper = el("div");
|
|
1212
|
+
css(wrapper, {
|
|
1213
|
+
position: "relative",
|
|
1214
|
+
display: "flex",
|
|
1215
|
+
alignItems: "flex-end"
|
|
1216
|
+
});
|
|
1217
|
+
const cell = el("span");
|
|
1218
|
+
css(cell, {
|
|
1219
|
+
fontSize: "var(--gantt-font-size-xs)",
|
|
1220
|
+
fontWeight: "var(--gantt-font-weight-bold)",
|
|
1221
|
+
color: "var(--gantt-text-secondary)",
|
|
1222
|
+
letterSpacing: "var(--gantt-letter-spacing-wide)",
|
|
1223
|
+
textTransform: "uppercase",
|
|
1224
|
+
paddingRight: "8px"
|
|
1225
|
+
});
|
|
1226
|
+
if (column.align !== void 0) cell.style.textAlign = column.align;
|
|
1227
|
+
cell.textContent = column.header;
|
|
1228
|
+
wrapper.append(cell);
|
|
1229
|
+
if (i < visible.length - 1) {
|
|
1230
|
+
const handle = el("div");
|
|
1231
|
+
handle.className = "gantt-col-resize-handle";
|
|
1232
|
+
css(handle, {
|
|
1233
|
+
position: "absolute",
|
|
1234
|
+
right: "-3px",
|
|
1235
|
+
top: "0",
|
|
1236
|
+
bottom: "0",
|
|
1237
|
+
width: "6px",
|
|
1238
|
+
cursor: "col-resize",
|
|
1239
|
+
zIndex: "1"
|
|
1240
|
+
});
|
|
1241
|
+
wrapper.append(handle);
|
|
1242
|
+
}
|
|
1243
|
+
header.append(wrapper);
|
|
1244
|
+
}
|
|
1245
|
+
return header;
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* 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.
|
|
1251
|
+
*/
|
|
1252
|
+
function setupColumnResize(headerEl, bodyEl, columns, onChange) {
|
|
1253
|
+
const handles = headerEl.querySelectorAll(".gantt-col-resize-handle");
|
|
1254
|
+
const cleanups = [];
|
|
1255
|
+
for (let colIndex = 0; colIndex < handles.length; colIndex++) {
|
|
1256
|
+
const handle = handles.item(colIndex);
|
|
1257
|
+
if (handle === null) continue;
|
|
1258
|
+
const capturedColIndex = colIndex;
|
|
1259
|
+
const onPointerDown = (e) => {
|
|
1260
|
+
if (e.button !== 0) return;
|
|
1261
|
+
e.preventDefault();
|
|
1262
|
+
e.stopPropagation();
|
|
1263
|
+
const startX = e.clientX;
|
|
1264
|
+
const startWidths = [...headerEl.children].map((c) => c.getBoundingClientRect().width);
|
|
1265
|
+
const onMove = (me) => {
|
|
1266
|
+
const dx = me.clientX - startX;
|
|
1267
|
+
const newWidths = [...startWidths];
|
|
1268
|
+
newWidths[capturedColIndex] = Math.max(COLUMN_MIN_WIDTH, (startWidths[capturedColIndex] ?? 0) + dx);
|
|
1269
|
+
if (capturedColIndex + 1 < newWidths.length) newWidths[capturedColIndex + 1] = Math.max(COLUMN_MIN_WIDTH, (startWidths[capturedColIndex + 1] ?? 0) - dx);
|
|
1270
|
+
const template = newWidths.map((w) => `${Math.round(w)}px`).join(" ");
|
|
1271
|
+
headerEl.style.gridTemplateColumns = template;
|
|
1272
|
+
const rows = bodyEl.querySelectorAll("[role=\"row\"]");
|
|
1273
|
+
for (const row of rows) row.style.gridTemplateColumns = template;
|
|
1274
|
+
};
|
|
1275
|
+
const onUp = () => {
|
|
1276
|
+
window.removeEventListener("pointermove", onMove);
|
|
1277
|
+
window.removeEventListener("pointerup", onUp);
|
|
1278
|
+
const finalCells = [...headerEl.children];
|
|
1279
|
+
const visible = visibleColumns(columns);
|
|
1280
|
+
for (let i = 0; i < visible.length && i < finalCells.length; i++) {
|
|
1281
|
+
const col = visible[i];
|
|
1282
|
+
const cell = finalCells[i];
|
|
1283
|
+
if (col !== void 0 && cell !== void 0) {
|
|
1284
|
+
const w = cell.getBoundingClientRect().width;
|
|
1285
|
+
col.width = `${Math.round(w)}px`;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
onChange?.([...columns]);
|
|
1289
|
+
};
|
|
1290
|
+
window.addEventListener("pointermove", onMove);
|
|
1291
|
+
window.addEventListener("pointerup", onUp);
|
|
1292
|
+
};
|
|
1293
|
+
handle.addEventListener("pointerdown", onPointerDown);
|
|
1294
|
+
cleanups.push(() => {
|
|
1295
|
+
handle.removeEventListener("pointerdown", onPointerDown);
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
return () => {
|
|
1299
|
+
for (const cleanup of cleanups) cleanup();
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
//#endregion
|
|
1303
|
+
//#region src/gantt-chart/vanilla/dom/dependencyLayer.ts
|
|
1304
|
+
const NS = "http://www.w3.org/2000/svg";
|
|
1305
|
+
const ARROW_SIZE = 6;
|
|
1306
|
+
/**
|
|
1307
|
+
* Creates the SVG overlay element. Call once; pass to updateDependencyLayer on each render.
|
|
1308
|
+
* Also creates a hidden ghost-line path used during link-creation drags.
|
|
1309
|
+
*/
|
|
1310
|
+
function createDependencyLayer(totalWidth, totalHeight) {
|
|
1311
|
+
const svg = document.createElementNS(NS, "svg");
|
|
1312
|
+
setAttrs(svg, {
|
|
1313
|
+
width: totalWidth,
|
|
1314
|
+
height: totalHeight
|
|
1315
|
+
});
|
|
1316
|
+
Object.assign(svg.style, {
|
|
1317
|
+
position: "absolute",
|
|
1318
|
+
top: "0",
|
|
1319
|
+
left: "0",
|
|
1320
|
+
pointerEvents: "none",
|
|
1321
|
+
overflow: "visible",
|
|
1322
|
+
zIndex: "1"
|
|
1323
|
+
});
|
|
1324
|
+
const defs = document.createElementNS(NS, "defs");
|
|
1325
|
+
for (const [id, color] of [["gantt-arrow", "var(--gantt-link)"], ["gantt-arrow-hi", "var(--gantt-link-hi)"]]) {
|
|
1326
|
+
const marker = document.createElementNS(NS, "marker");
|
|
1327
|
+
setAttrs(marker, {
|
|
1328
|
+
id,
|
|
1329
|
+
viewBox: "0 0 10 10",
|
|
1330
|
+
refX: "9",
|
|
1331
|
+
refY: "5",
|
|
1332
|
+
markerWidth: ARROW_SIZE,
|
|
1333
|
+
markerHeight: ARROW_SIZE,
|
|
1334
|
+
orient: "auto"
|
|
1335
|
+
});
|
|
1336
|
+
const path = document.createElementNS(NS, "path");
|
|
1337
|
+
setAttrs(path, {
|
|
1338
|
+
d: "M 0 1 L 10 5 L 0 9 Z",
|
|
1339
|
+
fill: color
|
|
1340
|
+
});
|
|
1341
|
+
marker.append(path);
|
|
1342
|
+
defs.append(marker);
|
|
1343
|
+
}
|
|
1344
|
+
svg.append(defs);
|
|
1345
|
+
const ghostPath = document.createElementNS(NS, "path");
|
|
1346
|
+
setAttrs(ghostPath, {
|
|
1347
|
+
d: "",
|
|
1348
|
+
fill: "none",
|
|
1349
|
+
stroke: "var(--gantt-link)",
|
|
1350
|
+
"stroke-width": "1.5",
|
|
1351
|
+
"stroke-dasharray": "5 3"
|
|
1352
|
+
});
|
|
1353
|
+
ghostPath.classList.add("gantt-ghost-line");
|
|
1354
|
+
ghostPath.style.display = "none";
|
|
1355
|
+
svg.append(ghostPath);
|
|
1356
|
+
return svg;
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* 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.
|
|
1361
|
+
*/
|
|
1362
|
+
function showGhostLine(svg, x1, y1, x2, y2, valid) {
|
|
1363
|
+
const ghost = svg.querySelector("path.gantt-ghost-line");
|
|
1364
|
+
if (ghost === null) return;
|
|
1365
|
+
setAttrs(ghost, {
|
|
1366
|
+
d: `M ${x1},${y1} L ${x2},${y2}`,
|
|
1367
|
+
"stroke-dasharray": valid ? "none" : "5 3"
|
|
1368
|
+
});
|
|
1369
|
+
if (valid) ghost.setAttribute("marker-end", "url(#gantt-arrow)");
|
|
1370
|
+
else ghost.removeAttribute("marker-end");
|
|
1371
|
+
ghost.style.display = "";
|
|
1372
|
+
}
|
|
1373
|
+
/**
|
|
1374
|
+
* Hides the ghost line after a link-creation drag completes or is cancelled.
|
|
1375
|
+
*/
|
|
1376
|
+
function hideGhostLine(svg) {
|
|
1377
|
+
const ghost = svg.querySelector("path.gantt-ghost-line");
|
|
1378
|
+
if (ghost !== null) {
|
|
1379
|
+
ghost.style.display = "none";
|
|
1380
|
+
ghost.removeAttribute("marker-end");
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Replaces all path elements in the SVG to reflect the current link set.
|
|
1385
|
+
* The `<defs>` node (first child) is preserved.
|
|
1386
|
+
*/
|
|
1387
|
+
function updateDependencyLayer(svg, links, totalWidth, totalHeight, selectedTaskId, highlightLinkedDependenciesOnSelect) {
|
|
1388
|
+
setAttrs(svg, {
|
|
1389
|
+
width: totalWidth,
|
|
1390
|
+
height: totalHeight
|
|
1391
|
+
});
|
|
1392
|
+
const toRemove = [];
|
|
1393
|
+
for (let i = 1; i < svg.children.length; i++) {
|
|
1394
|
+
const child = svg.children[i];
|
|
1395
|
+
if (child !== void 0 && !child.classList.contains("gantt-ghost-line")) toRemove.push(child);
|
|
1396
|
+
}
|
|
1397
|
+
for (const node of toRemove) svg.removeChild(node);
|
|
1398
|
+
for (const link of links) {
|
|
1399
|
+
const { points } = link;
|
|
1400
|
+
if (points.length === 0) continue;
|
|
1401
|
+
let d = `M ${points[0]?.x ?? 0},${points[0]?.y ?? 0}`;
|
|
1402
|
+
for (let i = 1; i < points.length; i++) {
|
|
1403
|
+
const p = points[i];
|
|
1404
|
+
if (p !== void 0) d += ` L ${p.x},${p.y}`;
|
|
1405
|
+
}
|
|
1406
|
+
const isRelated = highlightLinkedDependenciesOnSelect && selectedTaskId !== null && (link.sourceTaskId === selectedTaskId || link.targetTaskId === selectedTaskId);
|
|
1407
|
+
const path = document.createElementNS(NS, "path");
|
|
1408
|
+
setAttrs(path, {
|
|
1409
|
+
d,
|
|
1410
|
+
fill: "none",
|
|
1411
|
+
stroke: isRelated ? "var(--gantt-link-hi)" : "var(--gantt-link)",
|
|
1412
|
+
"stroke-width": isRelated ? "1.8" : "1.5",
|
|
1413
|
+
"stroke-linejoin": "round",
|
|
1414
|
+
"marker-end": isRelated ? "url(#gantt-arrow-hi)" : "url(#gantt-arrow)"
|
|
1415
|
+
});
|
|
1416
|
+
svg.append(path);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
//#endregion
|
|
1420
|
+
//#region src/gantt-chart/vanilla/interaction/drag.ts
|
|
1421
|
+
/**
|
|
1422
|
+
* Attaches drag-to-move and resize listeners to a bar element.
|
|
1423
|
+
* Returns a cleanup function that removes all listeners.
|
|
1424
|
+
*
|
|
1425
|
+
* Design: all mutable state lives in closure variables captured at mousedown.
|
|
1426
|
+
* No global state; multiple bars can be dragged independently (one at a time).
|
|
1427
|
+
*/
|
|
1428
|
+
function attachDrag(barEl, resizeHandleEl, task, getMapper, cbs) {
|
|
1429
|
+
function onBarDown(e) {
|
|
1430
|
+
if (e.button !== 0) return;
|
|
1431
|
+
e.preventDefault();
|
|
1432
|
+
try {
|
|
1433
|
+
barEl.setPointerCapture(e.pointerId);
|
|
1434
|
+
} catch {}
|
|
1435
|
+
cbs.onSelect?.(task.id);
|
|
1436
|
+
const startX = e.clientX;
|
|
1437
|
+
const originDate = parseDate(task.start_date);
|
|
1438
|
+
const mapper = getMapper();
|
|
1439
|
+
function onMove(me) {
|
|
1440
|
+
const dx = me.clientX - startX;
|
|
1441
|
+
const days = Math.round(mapper.widthToDuration(dx));
|
|
1442
|
+
cbs.onMove?.({
|
|
1443
|
+
id: task.id,
|
|
1444
|
+
startDate: addDays(originDate, days)
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
function onUp() {
|
|
1448
|
+
window.removeEventListener("pointermove", onMove);
|
|
1449
|
+
window.removeEventListener("pointerup", onUp);
|
|
1450
|
+
barEl.style.cursor = "grab";
|
|
1451
|
+
}
|
|
1452
|
+
barEl.style.cursor = "grabbing";
|
|
1453
|
+
window.addEventListener("pointermove", onMove);
|
|
1454
|
+
window.addEventListener("pointerup", onUp);
|
|
1455
|
+
}
|
|
1456
|
+
function onResizeDown(e) {
|
|
1457
|
+
if (e.button !== 0) return;
|
|
1458
|
+
e.preventDefault();
|
|
1459
|
+
e.stopPropagation();
|
|
1460
|
+
try {
|
|
1461
|
+
resizeHandleEl.setPointerCapture(e.pointerId);
|
|
1462
|
+
} catch {}
|
|
1463
|
+
const startX = e.clientX;
|
|
1464
|
+
const origDur = task.duration;
|
|
1465
|
+
const mapper = getMapper();
|
|
1466
|
+
function onMove(me) {
|
|
1467
|
+
const dx = me.clientX - startX;
|
|
1468
|
+
const daysDelta = Math.round(mapper.widthToDuration(dx));
|
|
1469
|
+
cbs.onResize?.({
|
|
1470
|
+
id: task.id,
|
|
1471
|
+
duration: Math.max(1, origDur + daysDelta)
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
function onUp() {
|
|
1475
|
+
window.removeEventListener("pointermove", onMove);
|
|
1476
|
+
window.removeEventListener("pointerup", onUp);
|
|
1477
|
+
}
|
|
1478
|
+
window.addEventListener("pointermove", onMove);
|
|
1479
|
+
window.addEventListener("pointerup", onUp);
|
|
1480
|
+
}
|
|
1481
|
+
function onBarClick(event) {
|
|
1482
|
+
if (event.detail !== 2) return;
|
|
1483
|
+
cbs.onTaskEditIntent?.({
|
|
1484
|
+
id: task.id,
|
|
1485
|
+
source: "bar",
|
|
1486
|
+
trigger: "double_click",
|
|
1487
|
+
task: toTask(task)
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
barEl.addEventListener("pointerdown", onBarDown);
|
|
1491
|
+
barEl.addEventListener("click", onBarClick);
|
|
1492
|
+
resizeHandleEl.addEventListener("pointerdown", onResizeDown);
|
|
1493
|
+
return () => {
|
|
1494
|
+
barEl.removeEventListener("pointerdown", onBarDown);
|
|
1495
|
+
barEl.removeEventListener("click", onBarClick);
|
|
1496
|
+
resizeHandleEl.removeEventListener("pointerdown", onResizeDown);
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Attaches click-to-select on a milestone diamond.
|
|
1501
|
+
* Returns cleanup.
|
|
1502
|
+
*/
|
|
1503
|
+
function attachMilestoneClick(diamondEl, taskId, cbs) {
|
|
1504
|
+
function onClick() {
|
|
1505
|
+
cbs.onSelect?.(taskId);
|
|
1506
|
+
}
|
|
1507
|
+
function onDoubleClick(event) {
|
|
1508
|
+
if (event.detail === 2) {
|
|
1509
|
+
const task = diamondEl.__task;
|
|
1510
|
+
if (task === void 0) return;
|
|
1511
|
+
cbs.onTaskEditIntent?.({
|
|
1512
|
+
id: taskId,
|
|
1513
|
+
source: "milestone",
|
|
1514
|
+
trigger: "double_click",
|
|
1515
|
+
task
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
diamondEl.addEventListener("click", onClick);
|
|
1520
|
+
diamondEl.addEventListener("click", onDoubleClick);
|
|
1521
|
+
return () => {
|
|
1522
|
+
diamondEl.removeEventListener("click", onClick);
|
|
1523
|
+
diamondEl.removeEventListener("click", onDoubleClick);
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
function bindMilestoneTask(diamondEl, task) {
|
|
1527
|
+
diamondEl.__task = task;
|
|
1528
|
+
}
|
|
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
|
+
//#endregion
|
|
1543
|
+
//#region src/gantt-chart/vanilla/interaction/linkCreation.ts
|
|
1544
|
+
/**
|
|
1545
|
+
* Attaches a link-creation drag listener to an endpoint handle.
|
|
1546
|
+
* Returns a cleanup function that removes all listeners.
|
|
1547
|
+
*/
|
|
1548
|
+
function attachLinkEndpointHandle(handle, sourceTaskId, anchorX, anchorY, svgLayer, absoluteLayer, cbs) {
|
|
1549
|
+
function onPointerDown(e) {
|
|
1550
|
+
if (e.button !== 0) return;
|
|
1551
|
+
e.preventDefault();
|
|
1552
|
+
e.stopPropagation();
|
|
1553
|
+
try {
|
|
1554
|
+
handle.setPointerCapture(e.pointerId);
|
|
1555
|
+
} catch {}
|
|
1556
|
+
let validTargetId = null;
|
|
1557
|
+
function onMove(me) {
|
|
1558
|
+
const layerRect = absoluteLayer.getBoundingClientRect();
|
|
1559
|
+
const x = me.clientX - layerRect.left;
|
|
1560
|
+
const y = me.clientY - layerRect.top;
|
|
1561
|
+
const barEl = document.elementFromPoint(me.clientX, me.clientY)?.closest("[data-task-id]");
|
|
1562
|
+
const targetId = barEl !== null && barEl !== void 0 ? Number(barEl.dataset["taskId"]) : null;
|
|
1563
|
+
validTargetId = targetId !== null && targetId !== sourceTaskId ? targetId : null;
|
|
1564
|
+
showGhostLine(svgLayer, anchorX, anchorY, x, y, validTargetId !== null);
|
|
1565
|
+
}
|
|
1566
|
+
function onUp() {
|
|
1567
|
+
window.removeEventListener("pointermove", onMove);
|
|
1568
|
+
window.removeEventListener("pointerup", onUp);
|
|
1569
|
+
hideGhostLine(svgLayer);
|
|
1570
|
+
if (validTargetId !== null) cbs.onLinkCreate?.({
|
|
1571
|
+
sourceTaskId,
|
|
1572
|
+
targetTaskId: validTargetId,
|
|
1573
|
+
type: "FS"
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
window.addEventListener("pointermove", onMove);
|
|
1577
|
+
window.addEventListener("pointerup", onUp);
|
|
1578
|
+
}
|
|
1579
|
+
handle.addEventListener("pointerdown", onPointerDown);
|
|
1580
|
+
handle.tabIndex = 0;
|
|
1581
|
+
handle.setAttribute("role", "button");
|
|
1582
|
+
handle.setAttribute("aria-label", `Create link from task ${sourceTaskId}`);
|
|
1583
|
+
function onKeyDown(event) {
|
|
1584
|
+
if (event.key === "Enter" || event.key === " ") event.preventDefault();
|
|
1585
|
+
}
|
|
1586
|
+
handle.addEventListener("keydown", onKeyDown);
|
|
1587
|
+
return () => {
|
|
1588
|
+
handle.removeEventListener("pointerdown", onPointerDown);
|
|
1589
|
+
handle.removeEventListener("keydown", onKeyDown);
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* Creates an endpoint handle DOM element.
|
|
1594
|
+
* The caller must position it with inline styles and append it to the layer.
|
|
1595
|
+
*/
|
|
1596
|
+
function createEndpointHandle() {
|
|
1597
|
+
const handle = document.createElement("div");
|
|
1598
|
+
handle.className = "gantt-link-endpoint";
|
|
1599
|
+
handle.style.position = "absolute";
|
|
1600
|
+
handle.style.width = "10px";
|
|
1601
|
+
handle.style.height = "10px";
|
|
1602
|
+
handle.style.borderRadius = "50%";
|
|
1603
|
+
handle.style.background = "var(--gantt-link)";
|
|
1604
|
+
handle.style.border = "2px solid var(--gantt-bg)";
|
|
1605
|
+
handle.style.cursor = "crosshair";
|
|
1606
|
+
handle.style.zIndex = "4";
|
|
1607
|
+
handle.style.opacity = "0";
|
|
1608
|
+
handle.style.transition = "opacity 0.15s ease, transform 0.1s ease";
|
|
1609
|
+
handle.style.transform = "translate(-50%, -50%) scale(0.8)";
|
|
1610
|
+
handle.style.pointerEvents = "auto";
|
|
1611
|
+
handle.style.touchAction = "none";
|
|
1612
|
+
return handle;
|
|
1613
|
+
}
|
|
1614
|
+
//#endregion
|
|
1615
|
+
//#region src/gantt-chart/vanilla/dom/rightPane.ts
|
|
1616
|
+
const BAR_COLOR = {
|
|
1617
|
+
task: "var(--gantt-task)",
|
|
1618
|
+
project: "var(--gantt-project)",
|
|
1619
|
+
milestone: "var(--gantt-milestone)"
|
|
1620
|
+
};
|
|
1621
|
+
/** Creates the skeleton DOM structure for the right pane. Call once. */
|
|
1622
|
+
function createRightPaneRefs() {
|
|
1623
|
+
const scrollContainer = el("div");
|
|
1624
|
+
const stripeContainer = el("div");
|
|
1625
|
+
const absoluteLayer = el("div");
|
|
1626
|
+
const svgLayer = createDependencyLayer(0, 0);
|
|
1627
|
+
css(stripeContainer, { position: "relative" });
|
|
1628
|
+
css(absoluteLayer, {
|
|
1629
|
+
position: "absolute",
|
|
1630
|
+
top: "0",
|
|
1631
|
+
left: "0"
|
|
1632
|
+
});
|
|
1633
|
+
scrollContainer.append(stripeContainer);
|
|
1634
|
+
scrollContainer.append(absoluteLayer);
|
|
1635
|
+
absoluteLayer.append(svgLayer);
|
|
1636
|
+
return {
|
|
1637
|
+
scrollContainer,
|
|
1638
|
+
stripeContainer,
|
|
1639
|
+
absoluteLayer,
|
|
1640
|
+
svgLayer,
|
|
1641
|
+
barRegistry: /* @__PURE__ */ new Map()
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
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);
|
|
1732
|
+
}
|
|
1733
|
+
function renderSpecialDayBackgrounds(layer, beforeNode, state, contentHeight) {
|
|
1734
|
+
const { mapper, viewportStart, viewportEnd, showWeekends, weekendDays, specialDaysByDate } = state;
|
|
1735
|
+
let cur = startOfDay(viewportStart);
|
|
1736
|
+
while (cur < viewportEnd) {
|
|
1737
|
+
const next = new Date(cur.getTime() + 864e5);
|
|
1738
|
+
const x = mapper.toX(cur);
|
|
1739
|
+
const width = Math.max(1, mapper.toX(next) - x);
|
|
1740
|
+
const dateKey = cur.toISOString().slice(0, 10);
|
|
1741
|
+
const specialDay = specialDaysByDate.get(dateKey);
|
|
1742
|
+
const isWeekend = weekendDays.has(cur.getUTCDay());
|
|
1743
|
+
let kind = null;
|
|
1744
|
+
if (specialDay !== void 0) {
|
|
1745
|
+
const { kind: specialKind } = specialDay;
|
|
1746
|
+
kind = specialKind;
|
|
1747
|
+
} else if (showWeekends && isWeekend) kind = "weekend";
|
|
1748
|
+
if (kind !== null) {
|
|
1749
|
+
const dayCell = el("div");
|
|
1750
|
+
dayCell.className = `gantt-day-cell gantt-day-cell--${kind}`;
|
|
1751
|
+
if (specialDay?.className !== void 0) dayCell.classList.add(specialDay.className);
|
|
1752
|
+
dayCell.dataset["date"] = dateKey;
|
|
1753
|
+
if (specialDay?.label !== void 0) {
|
|
1754
|
+
dayCell.dataset["label"] = specialDay.label;
|
|
1755
|
+
dayCell.title = specialDay.label;
|
|
1756
|
+
}
|
|
1757
|
+
css(dayCell, {
|
|
1758
|
+
position: "absolute",
|
|
1759
|
+
left: `${x}px`,
|
|
1760
|
+
top: "0",
|
|
1761
|
+
width: `${width}px`,
|
|
1762
|
+
height: `${contentHeight}px`,
|
|
1763
|
+
pointerEvents: "none",
|
|
1764
|
+
zIndex: "1"
|
|
1765
|
+
});
|
|
1766
|
+
layer.insertBefore(dayCell, beforeNode);
|
|
1767
|
+
}
|
|
1768
|
+
cur = next;
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
function renderBar(layer, svgLayer, task, layout, selectedId, registry, state, cbs) {
|
|
1772
|
+
const selected = task.id === selectedId;
|
|
1773
|
+
const color = BAR_COLOR[layout.type] ?? BAR_COLOR["task"];
|
|
1774
|
+
const bar = el("div");
|
|
1775
|
+
bar.className = `gantt-bar${selected ? " gantt-bar--selected gantt-shape--selected" : ""}`;
|
|
1776
|
+
css(bar, {
|
|
1777
|
+
position: "absolute",
|
|
1778
|
+
left: `${layout.x}px`,
|
|
1779
|
+
top: `${layout.y}px`,
|
|
1780
|
+
width: `${layout.width}px`,
|
|
1781
|
+
height: `${layout.height}px`,
|
|
1782
|
+
...color === void 0 ? {} : { background: color },
|
|
1783
|
+
borderRadius: layout.type === "project" ? "3px" : "4px",
|
|
1784
|
+
cursor: "grab",
|
|
1785
|
+
userSelect: "none",
|
|
1786
|
+
overflow: "hidden",
|
|
1787
|
+
zIndex: selected ? "3" : "2",
|
|
1788
|
+
touchAction: "none"
|
|
1789
|
+
});
|
|
1790
|
+
if (layout.progressWidth > 0) {
|
|
1791
|
+
const prog = el("div");
|
|
1792
|
+
css(prog, {
|
|
1793
|
+
position: "absolute",
|
|
1794
|
+
left: "0",
|
|
1795
|
+
top: "0",
|
|
1796
|
+
width: `${layout.progressWidth}px`,
|
|
1797
|
+
height: "100%",
|
|
1798
|
+
background: "rgba(0,0,0,0.18)",
|
|
1799
|
+
pointerEvents: "none"
|
|
1800
|
+
});
|
|
1801
|
+
bar.append(prog);
|
|
1802
|
+
}
|
|
1803
|
+
const label = el("span");
|
|
1804
|
+
css(label, {
|
|
1805
|
+
position: "absolute",
|
|
1806
|
+
left: "8px",
|
|
1807
|
+
right: "8px",
|
|
1808
|
+
top: "50%",
|
|
1809
|
+
transform: "translateY(-50%)",
|
|
1810
|
+
overflow: "hidden",
|
|
1811
|
+
textOverflow: "ellipsis",
|
|
1812
|
+
color: "var(--gantt-bar-label-color)",
|
|
1813
|
+
fontSize: "var(--gantt-font-size-sm)",
|
|
1814
|
+
fontWeight: "var(--gantt-font-weight-semibold)",
|
|
1815
|
+
whiteSpace: "nowrap",
|
|
1816
|
+
pointerEvents: "none",
|
|
1817
|
+
textShadow: "0 1px 2px rgba(0,0,0,0.25)"
|
|
1818
|
+
});
|
|
1819
|
+
label.textContent = task.text;
|
|
1820
|
+
bar.append(label);
|
|
1821
|
+
bar.tabIndex = 0;
|
|
1822
|
+
bar.setAttribute("role", "button");
|
|
1823
|
+
bar.setAttribute("aria-label", ariaLabel(state.locale, "aria_task", task.text));
|
|
1824
|
+
bar.setAttribute("aria-pressed", String(selected));
|
|
1825
|
+
bar.dataset["taskId"] = String(task.id);
|
|
1826
|
+
bar.addEventListener("click", () => {
|
|
1827
|
+
cbs.onSelect?.(task.id);
|
|
1828
|
+
});
|
|
1829
|
+
bar.addEventListener("keydown", (event) => {
|
|
1830
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
1831
|
+
event.preventDefault();
|
|
1832
|
+
cbs.onSelect?.(task.id);
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
const handle = el("div");
|
|
1836
|
+
handle.className = "gantt-resize-handle";
|
|
1837
|
+
css(handle, {
|
|
1838
|
+
position: "absolute",
|
|
1839
|
+
right: "0",
|
|
1840
|
+
top: "0",
|
|
1841
|
+
width: "8px",
|
|
1842
|
+
height: "100%",
|
|
1843
|
+
cursor: "ew-resize",
|
|
1844
|
+
zIndex: "1",
|
|
1845
|
+
touchAction: "none"
|
|
1846
|
+
});
|
|
1847
|
+
bar.append(handle);
|
|
1848
|
+
layer.insertBefore(bar, svgLayer);
|
|
1849
|
+
const cleanupDrag = attachDrag(bar, handle, task, () => state.mapper, cbs);
|
|
1850
|
+
let cleanupLinkHandles;
|
|
1851
|
+
if (state.linkCreationEnabled) {
|
|
1852
|
+
const barCenterY = layout.y + layout.height / 2;
|
|
1853
|
+
const leftHandle = createEndpointHandle();
|
|
1854
|
+
leftHandle.style.left = `${layout.x}px`;
|
|
1855
|
+
leftHandle.style.top = `${barCenterY}px`;
|
|
1856
|
+
layer.insertBefore(leftHandle, svgLayer);
|
|
1857
|
+
const rightHandle = createEndpointHandle();
|
|
1858
|
+
rightHandle.style.left = `${layout.x + layout.width}px`;
|
|
1859
|
+
rightHandle.style.top = `${barCenterY}px`;
|
|
1860
|
+
layer.insertBefore(rightHandle, svgLayer);
|
|
1861
|
+
const cleanupLeft = attachLinkEndpointHandle(leftHandle, task.id, layout.x, barCenterY, svgLayer, layer, cbs);
|
|
1862
|
+
const cleanupRight = attachLinkEndpointHandle(rightHandle, task.id, layout.x + layout.width, barCenterY, svgLayer, layer, cbs);
|
|
1863
|
+
const onBarEnter = () => {
|
|
1864
|
+
leftHandle.style.opacity = "1";
|
|
1865
|
+
rightHandle.style.opacity = "1";
|
|
1866
|
+
leftHandle.style.transform = "translate(-50%, -50%) scale(1)";
|
|
1867
|
+
rightHandle.style.transform = "translate(-50%, -50%) scale(1)";
|
|
1868
|
+
};
|
|
1869
|
+
const onBarLeave = () => {
|
|
1870
|
+
leftHandle.style.opacity = "0";
|
|
1871
|
+
rightHandle.style.opacity = "0";
|
|
1872
|
+
leftHandle.style.transform = "translate(-50%, -50%) scale(0.8)";
|
|
1873
|
+
rightHandle.style.transform = "translate(-50%, -50%) scale(0.8)";
|
|
1874
|
+
};
|
|
1875
|
+
bar.addEventListener("mouseenter", onBarEnter);
|
|
1876
|
+
bar.addEventListener("mouseleave", onBarLeave);
|
|
1877
|
+
cleanupLinkHandles = () => {
|
|
1878
|
+
cleanupLeft();
|
|
1879
|
+
cleanupRight();
|
|
1880
|
+
bar.removeEventListener("mouseenter", onBarEnter);
|
|
1881
|
+
bar.removeEventListener("mouseleave", onBarLeave);
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
const entry = {
|
|
1885
|
+
bar,
|
|
1886
|
+
resizeHandle: handle,
|
|
1887
|
+
cleanupDrag
|
|
1888
|
+
};
|
|
1889
|
+
if (cleanupLinkHandles !== void 0) entry.cleanupLinkHandles = cleanupLinkHandles;
|
|
1890
|
+
registry.set(task.id, entry);
|
|
1891
|
+
}
|
|
1892
|
+
function renderMilestone(layer, svgLayer, task, layout, selectedId, registry, cbs, state) {
|
|
1893
|
+
const selected = task.id === selectedId;
|
|
1894
|
+
const size = MILESTONE_HALF * 2;
|
|
1895
|
+
const diamond = el("div");
|
|
1896
|
+
diamond.className = `gantt-milestone${selected ? " gantt-shape--selected" : ""}`;
|
|
1897
|
+
css(diamond, {
|
|
1898
|
+
position: "absolute",
|
|
1899
|
+
left: `${layout.x - MILESTONE_HALF}px`,
|
|
1900
|
+
top: `${layout.y + (layout.height - size) / 2}px`,
|
|
1901
|
+
width: `${size}px`,
|
|
1902
|
+
height: `${size}px`,
|
|
1903
|
+
background: "var(--gantt-milestone)",
|
|
1904
|
+
transform: "rotate(45deg)",
|
|
1905
|
+
cursor: "pointer",
|
|
1906
|
+
zIndex: "4"
|
|
1907
|
+
});
|
|
1908
|
+
diamond.tabIndex = 0;
|
|
1909
|
+
diamond.setAttribute("role", "button");
|
|
1910
|
+
diamond.setAttribute("aria-label", ariaLabel(state.locale, "aria_milestone", task.text));
|
|
1911
|
+
diamond.setAttribute("aria-pressed", String(selected));
|
|
1912
|
+
diamond.dataset["taskId"] = String(task.id);
|
|
1913
|
+
diamond.addEventListener("keydown", (event) => {
|
|
1914
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
1915
|
+
event.preventDefault();
|
|
1916
|
+
cbs.onSelect?.(task.id);
|
|
1917
|
+
}
|
|
1918
|
+
});
|
|
1919
|
+
const labelEl = el("span");
|
|
1920
|
+
css(labelEl, {
|
|
1921
|
+
position: "absolute",
|
|
1922
|
+
left: "50%",
|
|
1923
|
+
top: "110%",
|
|
1924
|
+
transform: "translate(-50%, 0) rotate(-45deg)",
|
|
1925
|
+
fontSize: "var(--gantt-font-size-xs)",
|
|
1926
|
+
fontWeight: "var(--gantt-font-weight-semibold)",
|
|
1927
|
+
color: "var(--gantt-milestone)",
|
|
1928
|
+
whiteSpace: "nowrap",
|
|
1929
|
+
pointerEvents: "none"
|
|
1930
|
+
});
|
|
1931
|
+
labelEl.textContent = task.text;
|
|
1932
|
+
diamond.append(labelEl);
|
|
1933
|
+
layer.insertBefore(diamond, svgLayer);
|
|
1934
|
+
bindMilestoneTask(diamond, task);
|
|
1935
|
+
const dummy = el("div");
|
|
1936
|
+
const cleanupDrag = attachMilestoneClick(diamond, task.id, cbs);
|
|
1937
|
+
let cleanupLinkHandles;
|
|
1938
|
+
if (state.linkCreationEnabled) {
|
|
1939
|
+
const diamondCenterY = layout.y + layout.height / 2;
|
|
1940
|
+
const linkHandle = createEndpointHandle();
|
|
1941
|
+
linkHandle.style.left = `${layout.x}px`;
|
|
1942
|
+
linkHandle.style.top = `${diamondCenterY}px`;
|
|
1943
|
+
linkHandle.style.background = "var(--gantt-milestone)";
|
|
1944
|
+
layer.insertBefore(linkHandle, svgLayer);
|
|
1945
|
+
const cleanupLink = attachLinkEndpointHandle(linkHandle, task.id, layout.x, diamondCenterY, svgLayer, layer, cbs);
|
|
1946
|
+
const onDiamondEnter = () => {
|
|
1947
|
+
linkHandle.style.opacity = "1";
|
|
1948
|
+
linkHandle.style.transform = "translate(-50%, -50%) scale(1)";
|
|
1949
|
+
};
|
|
1950
|
+
const onDiamondLeave = () => {
|
|
1951
|
+
linkHandle.style.opacity = "0";
|
|
1952
|
+
linkHandle.style.transform = "translate(-50%, -50%) scale(0.8)";
|
|
1953
|
+
};
|
|
1954
|
+
diamond.addEventListener("mouseenter", onDiamondEnter);
|
|
1955
|
+
diamond.addEventListener("mouseleave", onDiamondLeave);
|
|
1956
|
+
cleanupLinkHandles = () => {
|
|
1957
|
+
cleanupLink();
|
|
1958
|
+
diamond.removeEventListener("mouseenter", onDiamondEnter);
|
|
1959
|
+
diamond.removeEventListener("mouseleave", onDiamondLeave);
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
const entry = {
|
|
1963
|
+
bar: diamond,
|
|
1964
|
+
resizeHandle: dummy,
|
|
1965
|
+
cleanupDrag
|
|
1966
|
+
};
|
|
1967
|
+
if (cleanupLinkHandles !== void 0) entry.cleanupLinkHandles = cleanupLinkHandles;
|
|
1968
|
+
registry.set(task.id, entry);
|
|
1969
|
+
}
|
|
1970
|
+
function ariaLabel(locale, key, arg) {
|
|
1971
|
+
return formatLabel(locale.labels?.[key] ?? EN_US_LABELS[key], arg);
|
|
1972
|
+
}
|
|
1973
|
+
//#endregion
|
|
1974
|
+
//#region src/gantt-chart/vanilla/utils.ts
|
|
1975
|
+
function buildTaskIndex(tasks) {
|
|
1976
|
+
const index = /* @__PURE__ */ new Map();
|
|
1977
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
1978
|
+
const task = tasks[i];
|
|
1979
|
+
if (task !== void 0) index.set(task.id, i);
|
|
1980
|
+
}
|
|
1981
|
+
return index;
|
|
1982
|
+
}
|
|
1983
|
+
function buildSpecialDayIndex(specialDays) {
|
|
1984
|
+
const map = /* @__PURE__ */ new Map();
|
|
1985
|
+
for (const specialDay of specialDays) {
|
|
1986
|
+
const parsed = SpecialDaySchema.parse(specialDay);
|
|
1987
|
+
const isoDate = toIsoDate(parseDate(parsed.date));
|
|
1988
|
+
map.set(isoDate, {
|
|
1989
|
+
kind: parsed.kind,
|
|
1990
|
+
...parsed.label === void 0 ? {} : { label: parsed.label },
|
|
1991
|
+
...parsed.className === void 0 ? {} : { className: parsed.className }
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
return map;
|
|
1995
|
+
}
|
|
1996
|
+
function toIsoDate(date) {
|
|
1997
|
+
return date.toISOString().slice(0, 10);
|
|
1998
|
+
}
|
|
1999
|
+
function normalizeWeekendDays(days) {
|
|
2000
|
+
if (days === void 0) return new Set([0, 6]);
|
|
2001
|
+
const normalized = /* @__PURE__ */ new Set();
|
|
2002
|
+
for (const day of days) {
|
|
2003
|
+
if (!Number.isInteger(day) || day < 0 || day > 6) throw new Error("weekendDays must contain integers in range 0..6");
|
|
2004
|
+
normalized.add(day);
|
|
2005
|
+
}
|
|
2006
|
+
return normalized;
|
|
2007
|
+
}
|
|
2008
|
+
function getExpandableTaskIds(tasks) {
|
|
2009
|
+
const roots = buildTaskTree(tasks);
|
|
2010
|
+
const expandableIds = /* @__PURE__ */ new Set();
|
|
2011
|
+
const stack = [...roots];
|
|
2012
|
+
while (stack.length > 0) {
|
|
2013
|
+
const node = stack.pop();
|
|
2014
|
+
if (node === void 0) continue;
|
|
2015
|
+
if (node.children.length > 0) expandableIds.add(node.id);
|
|
2016
|
+
for (const child of node.children) stack.push(child);
|
|
2017
|
+
}
|
|
2018
|
+
return expandableIds;
|
|
2019
|
+
}
|
|
2020
|
+
function getInitialExpandedIds(tasks) {
|
|
2021
|
+
const expandableIds = getExpandableTaskIds(tasks);
|
|
2022
|
+
const expandedIds = /* @__PURE__ */ new Set();
|
|
2023
|
+
for (const task of tasks) if (task.open && expandableIds.has(task.id)) expandedIds.add(task.id);
|
|
2024
|
+
return expandedIds;
|
|
2025
|
+
}
|
|
2026
|
+
//#endregion
|
|
2027
|
+
//#region src/gantt-chart/vanilla/splitter.ts
|
|
2028
|
+
const MIN_PANE_WIDTH$1 = 96;
|
|
2029
|
+
function attachSplitter(splitterHandle, leftPane, container, timelineMinWidth, onDragEnd) {
|
|
2030
|
+
splitterHandle.addEventListener("pointerdown", (e) => {
|
|
2031
|
+
if (e.button !== 0) return;
|
|
2032
|
+
e.preventDefault();
|
|
2033
|
+
e.stopPropagation();
|
|
2034
|
+
const startX = e.clientX;
|
|
2035
|
+
const startWidth = Number.parseFloat(leftPane.style.width) || 0;
|
|
2036
|
+
function onMove(me) {
|
|
2037
|
+
let newWidth = startWidth + (me.clientX - startX);
|
|
2038
|
+
const hostWidth = container.clientWidth;
|
|
2039
|
+
if (hostWidth > 0) newWidth = Math.max(MIN_PANE_WIDTH$1, Math.min(newWidth, hostWidth - timelineMinWidth));
|
|
2040
|
+
newWidth = Math.max(MIN_PANE_WIDTH$1, newWidth);
|
|
2041
|
+
leftPane.style.width = `${newWidth}px`;
|
|
2042
|
+
leftPane.style.minWidth = `${newWidth}px`;
|
|
2043
|
+
leftPane.style.maxWidth = `${newWidth}px`;
|
|
2044
|
+
}
|
|
2045
|
+
function onUp() {
|
|
2046
|
+
window.removeEventListener("pointermove", onMove);
|
|
2047
|
+
window.removeEventListener("pointerup", onUp);
|
|
2048
|
+
onDragEnd(Number.parseFloat(leftPane.style.width));
|
|
2049
|
+
}
|
|
2050
|
+
window.addEventListener("pointermove", onMove);
|
|
2051
|
+
window.addEventListener("pointerup", onUp);
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
const DESKTOP_MIN_RATIO = .25;
|
|
2055
|
+
const DESKTOP_MAX_RATIO = .4;
|
|
2056
|
+
const MIN_PANE_WIDTH = 96;
|
|
2057
|
+
function computeLeftPaneWidth(options) {
|
|
2058
|
+
const { hostWidth, defaultWidth, userSplitWidth, explicitOptWidth, responsiveSplitPane, mobileBreakpoint, mobileLeftPaneMinWidth, mobileLeftPaneMaxRatio, timelineMinWidth } = options;
|
|
2059
|
+
let width = defaultWidth;
|
|
2060
|
+
if (hostWidth <= 0) return width;
|
|
2061
|
+
if (userSplitWidth !== null) width = userSplitWidth;
|
|
2062
|
+
else if (explicitOptWidth !== void 0) width = explicitOptWidth;
|
|
2063
|
+
else if (responsiveSplitPane && hostWidth <= mobileBreakpoint) {
|
|
2064
|
+
const ratioWidth = Math.floor(hostWidth * mobileLeftPaneMaxRatio);
|
|
2065
|
+
width = Math.min(defaultWidth, Math.max(mobileLeftPaneMinWidth, ratioWidth));
|
|
2066
|
+
} else {
|
|
2067
|
+
const minProportional = Math.floor(hostWidth * DESKTOP_MIN_RATIO);
|
|
2068
|
+
const maxProportional = Math.floor(hostWidth * DESKTOP_MAX_RATIO);
|
|
2069
|
+
width = Math.min(maxProportional, Math.max(defaultWidth, minProportional));
|
|
2070
|
+
}
|
|
2071
|
+
const maxAllowed = Math.max(MIN_PANE_WIDTH, hostWidth - timelineMinWidth);
|
|
2072
|
+
width = Math.min(width, maxAllowed);
|
|
2073
|
+
return Math.max(MIN_PANE_WIDTH, Math.floor(width));
|
|
2074
|
+
}
|
|
2075
|
+
//#endregion
|
|
2076
|
+
//#region src/gantt-chart/vanilla/gantt-chart.ts
|
|
2077
|
+
const HEADER_H = 52;
|
|
2078
|
+
const OVERSCAN = 4;
|
|
2079
|
+
var GanttChart = class {
|
|
2080
|
+
#container;
|
|
2081
|
+
#opts;
|
|
2082
|
+
#input;
|
|
2083
|
+
#scale;
|
|
2084
|
+
#selectedId = null;
|
|
2085
|
+
#scrollTop = 0;
|
|
2086
|
+
#rafPending = false;
|
|
2087
|
+
#rafId = null;
|
|
2088
|
+
#destroyed = false;
|
|
2089
|
+
#taskIndex;
|
|
2090
|
+
#lastGridClick = null;
|
|
2091
|
+
#userSplitWidth = null;
|
|
2092
|
+
#height;
|
|
2093
|
+
#locale;
|
|
2094
|
+
#timelineMinWidth;
|
|
2095
|
+
#columns;
|
|
2096
|
+
#leftPaneDefaultWidth;
|
|
2097
|
+
#weekendDays;
|
|
2098
|
+
#specialDaysByDate;
|
|
2099
|
+
#expandedIds;
|
|
2100
|
+
#root;
|
|
2101
|
+
#scrollEl;
|
|
2102
|
+
#leftPane;
|
|
2103
|
+
#leftBody;
|
|
2104
|
+
#rightPane;
|
|
2105
|
+
#rightHeader;
|
|
2106
|
+
#rightPaneRefs;
|
|
2107
|
+
#cbs;
|
|
2108
|
+
#resizeObserver = null;
|
|
2109
|
+
#columnResizeCleanup;
|
|
2110
|
+
constructor(container, input, opts = {}) {
|
|
2111
|
+
this.#container = container;
|
|
2112
|
+
validateLinkRefs(input.tasks, input.links);
|
|
2113
|
+
detectCycles(input.tasks, input.links);
|
|
2114
|
+
this.#input = input;
|
|
2115
|
+
this.#scale = opts.scale ?? "day";
|
|
2116
|
+
this.#opts = opts;
|
|
2117
|
+
this.#taskIndex = buildTaskIndex(input.tasks);
|
|
2118
|
+
this.#locale = resolveChartLocale(opts.locale);
|
|
2119
|
+
this.#columns = opts.gridColumns ?? gridColumnDefaults(this.#locale);
|
|
2120
|
+
this.#leftPaneDefaultWidth = opts.leftPaneWidth ?? gridNaturalWidth(this.#columns);
|
|
2121
|
+
this.#height = opts.height ?? 500;
|
|
2122
|
+
this.#timelineMinWidth = opts.timelineMinWidth ?? 220;
|
|
2123
|
+
this.#weekendDays = normalizeWeekendDays(opts.weekendDays ?? this.#locale.weekendDays);
|
|
2124
|
+
this.#specialDaysByDate = buildSpecialDayIndex(opts.specialDays ?? []);
|
|
2125
|
+
this.#expandedIds = getInitialExpandedIds(input.tasks);
|
|
2126
|
+
this.#cbs = {
|
|
2127
|
+
onSelect: (id) => {
|
|
2128
|
+
if (this.#selectedId === id) return;
|
|
2129
|
+
this.#selectedId = id;
|
|
2130
|
+
opts.onSelect?.(this.#selectedId);
|
|
2131
|
+
this.#scheduleRender();
|
|
2132
|
+
},
|
|
2133
|
+
onTaskDoubleClick: (payload) => {
|
|
2134
|
+
opts.onTaskDoubleClick?.(payload);
|
|
2135
|
+
},
|
|
2136
|
+
onTaskEditIntent: (payload) => {
|
|
2137
|
+
opts.onTaskEditIntent?.(payload);
|
|
2138
|
+
opts.onTaskDoubleClick?.({
|
|
2139
|
+
id: payload.id,
|
|
2140
|
+
source: payload.source
|
|
2141
|
+
});
|
|
2142
|
+
},
|
|
2143
|
+
onMove: (payload) => {
|
|
2144
|
+
const iso = payload.startDate.toISOString().slice(0, 10);
|
|
2145
|
+
this.#patchTask(payload.id, { start_date: iso });
|
|
2146
|
+
opts.onMove?.(payload);
|
|
2147
|
+
this.#scheduleRender();
|
|
2148
|
+
},
|
|
2149
|
+
onResize: (payload) => {
|
|
2150
|
+
this.#patchTask(payload.id, { duration: payload.duration });
|
|
2151
|
+
opts.onResize?.(payload);
|
|
2152
|
+
this.#scheduleRender();
|
|
2153
|
+
},
|
|
2154
|
+
onLeftPaneWidthChange: (width) => {
|
|
2155
|
+
opts.onLeftPaneWidthChange?.(width);
|
|
2156
|
+
},
|
|
2157
|
+
onGridColumnsChange: (updatedColumns) => {
|
|
2158
|
+
opts.onGridColumnsChange?.(updatedColumns);
|
|
2159
|
+
},
|
|
2160
|
+
onLinkCreate: (payload) => {
|
|
2161
|
+
opts.onLinkCreate?.(payload);
|
|
2162
|
+
}
|
|
2163
|
+
};
|
|
2164
|
+
this.#buildDom();
|
|
2165
|
+
this.#wireEvents();
|
|
2166
|
+
container.append(this.#root);
|
|
2167
|
+
this.#applyTheme();
|
|
2168
|
+
this.#applyResponsivePaneStyles();
|
|
2169
|
+
this.#setupResizeObserver();
|
|
2170
|
+
this.#render();
|
|
2171
|
+
}
|
|
2172
|
+
update(newInput) {
|
|
2173
|
+
this.#assertAlive();
|
|
2174
|
+
validateLinkRefs(newInput.tasks, newInput.links);
|
|
2175
|
+
detectCycles(newInput.tasks, newInput.links);
|
|
2176
|
+
this.#input = newInput;
|
|
2177
|
+
this.#taskIndex = buildTaskIndex(newInput.tasks);
|
|
2178
|
+
this.#scheduleRender();
|
|
2179
|
+
}
|
|
2180
|
+
setScale(scale) {
|
|
2181
|
+
this.#assertAlive();
|
|
2182
|
+
this.#scale = scale;
|
|
2183
|
+
this.#scheduleRender();
|
|
2184
|
+
}
|
|
2185
|
+
select(id) {
|
|
2186
|
+
this.#assertAlive();
|
|
2187
|
+
this.#selectedId = id;
|
|
2188
|
+
this.#opts.onSelect?.(id);
|
|
2189
|
+
if (this.#rafPending && this.#rafId !== null) {
|
|
2190
|
+
cancelAnimationFrame(this.#rafId);
|
|
2191
|
+
this.#rafId = null;
|
|
2192
|
+
this.#rafPending = false;
|
|
2193
|
+
}
|
|
2194
|
+
this.#render();
|
|
2195
|
+
}
|
|
2196
|
+
collapseAll() {
|
|
2197
|
+
this.#assertAlive();
|
|
2198
|
+
this.#expandedIds.clear();
|
|
2199
|
+
if (this.#rafPending && this.#rafId !== null) {
|
|
2200
|
+
cancelAnimationFrame(this.#rafId);
|
|
2201
|
+
this.#rafId = null;
|
|
2202
|
+
this.#rafPending = false;
|
|
2203
|
+
}
|
|
2204
|
+
this.#render();
|
|
2205
|
+
}
|
|
2206
|
+
expandAll() {
|
|
2207
|
+
this.#assertAlive();
|
|
2208
|
+
this.#expandedIds.clear();
|
|
2209
|
+
for (const id of getExpandableTaskIds(this.#input.tasks)) this.#expandedIds.add(id);
|
|
2210
|
+
if (this.#rafPending && this.#rafId !== null) {
|
|
2211
|
+
cancelAnimationFrame(this.#rafId);
|
|
2212
|
+
this.#rafId = null;
|
|
2213
|
+
this.#rafPending = false;
|
|
2214
|
+
}
|
|
2215
|
+
this.#render();
|
|
2216
|
+
}
|
|
2217
|
+
destroy() {
|
|
2218
|
+
if (this.#destroyed) return;
|
|
2219
|
+
this.#destroyed = true;
|
|
2220
|
+
this.#scrollEl.removeEventListener("scroll", this.#onScroll);
|
|
2221
|
+
if (this.#resizeObserver !== null) this.#resizeObserver.disconnect();
|
|
2222
|
+
else window.removeEventListener("resize", this.#applyResponsivePaneStyles);
|
|
2223
|
+
if (this.#rafId !== null) cancelAnimationFrame(this.#rafId);
|
|
2224
|
+
this.#columnResizeCleanup();
|
|
2225
|
+
for (const { cleanupDrag, cleanupLinkHandles } of this.#rightPaneRefs.barRegistry.values()) {
|
|
2226
|
+
cleanupDrag();
|
|
2227
|
+
cleanupLinkHandles?.();
|
|
2228
|
+
}
|
|
2229
|
+
clearChildren(this.#container);
|
|
2230
|
+
}
|
|
2231
|
+
#patchTask(id, patch) {
|
|
2232
|
+
const index = this.#taskIndex.get(id);
|
|
2233
|
+
if (index === void 0) return;
|
|
2234
|
+
const target = this.#input.tasks[index];
|
|
2235
|
+
if (target === void 0) return;
|
|
2236
|
+
this.#input.tasks[index] = {
|
|
2237
|
+
...target,
|
|
2238
|
+
...patch
|
|
2239
|
+
};
|
|
2240
|
+
}
|
|
2241
|
+
#handleGridClick = (payload) => {
|
|
2242
|
+
const now = Date.now();
|
|
2243
|
+
const prev = this.#lastGridClick;
|
|
2244
|
+
if (prev !== null && prev.id === payload.id && now - prev.atMs <= 350) {
|
|
2245
|
+
this.#lastGridClick = null;
|
|
2246
|
+
this.#cbs.onTaskEditIntent?.({
|
|
2247
|
+
id: payload.id,
|
|
2248
|
+
source: "grid",
|
|
2249
|
+
trigger: "double_click",
|
|
2250
|
+
task: payload.task
|
|
2251
|
+
});
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
this.#lastGridClick = {
|
|
2255
|
+
id: payload.id,
|
|
2256
|
+
atMs: now
|
|
2257
|
+
};
|
|
2258
|
+
this.#cbs.onSelect?.(payload.id);
|
|
2259
|
+
};
|
|
2260
|
+
#onScroll = () => {
|
|
2261
|
+
({scrollTop: this.#scrollTop} = this.#scrollEl);
|
|
2262
|
+
this.#scheduleRender();
|
|
2263
|
+
};
|
|
2264
|
+
#applyResponsivePaneStyles = () => {
|
|
2265
|
+
const computedWidth = computeLeftPaneWidth({
|
|
2266
|
+
hostWidth: Math.max(0, this.#container.clientWidth),
|
|
2267
|
+
defaultWidth: this.#leftPaneDefaultWidth,
|
|
2268
|
+
userSplitWidth: this.#userSplitWidth,
|
|
2269
|
+
explicitOptWidth: this.#opts.leftPaneWidth,
|
|
2270
|
+
responsiveSplitPane: this.#opts.responsiveSplitPane ?? true,
|
|
2271
|
+
mobileBreakpoint: this.#opts.mobileBreakpoint ?? 768,
|
|
2272
|
+
mobileLeftPaneMinWidth: this.#opts.mobileLeftPaneMinWidth ?? 140,
|
|
2273
|
+
mobileLeftPaneMaxRatio: this.#opts.mobileLeftPaneMaxRatio ?? .45,
|
|
2274
|
+
timelineMinWidth: this.#timelineMinWidth
|
|
2275
|
+
});
|
|
2276
|
+
this.#leftPane.style.width = `${computedWidth}px`;
|
|
2277
|
+
this.#leftPane.style.minWidth = `${computedWidth}px`;
|
|
2278
|
+
this.#leftPane.style.maxWidth = `${computedWidth}px`;
|
|
2279
|
+
this.#rightPane.style.minWidth = `${this.#timelineMinWidth}px`;
|
|
2280
|
+
};
|
|
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);
|
|
2284
|
+
const mapper = createPixelMapper(this.#scale, vpStart);
|
|
2285
|
+
const totalWidth = Math.ceil(mapper.toX(vpEnd)) + 1;
|
|
2286
|
+
const layouts = computeLayout(allRows, mapper);
|
|
2287
|
+
const links = routeLinks(this.#input.links, layouts);
|
|
2288
|
+
const containerH = this.#height - HEADER_H;
|
|
2289
|
+
const rowCount = allRows.length;
|
|
2290
|
+
const startIndex = Math.max(0, Math.floor(this.#scrollTop / ROW_HEIGHT) - OVERSCAN);
|
|
2291
|
+
const endIndex = Math.min(rowCount - 1, Math.ceil((this.#scrollTop + containerH) / ROW_HEIGHT) + OVERSCAN - 1);
|
|
2292
|
+
const paddingTop = startIndex * ROW_HEIGHT;
|
|
2293
|
+
const paddingBottom = Math.max(0, (rowCount - 1 - endIndex) * ROW_HEIGHT);
|
|
2294
|
+
return {
|
|
2295
|
+
input: this.#input,
|
|
2296
|
+
scale: this.#scale,
|
|
2297
|
+
highlightLinkedDependenciesOnSelect: this.#opts.highlightLinkedDependenciesOnSelect ?? false,
|
|
2298
|
+
linkCreationEnabled: this.#opts.linkCreationEnabled ?? false,
|
|
2299
|
+
expandedIds: this.#expandedIds,
|
|
2300
|
+
selectedId: this.#selectedId,
|
|
2301
|
+
scrollTop: this.#scrollTop,
|
|
2302
|
+
allRows,
|
|
2303
|
+
mapper,
|
|
2304
|
+
viewportStart: vpStart,
|
|
2305
|
+
viewportEnd: vpEnd,
|
|
2306
|
+
totalWidth,
|
|
2307
|
+
layouts,
|
|
2308
|
+
links,
|
|
2309
|
+
startIndex,
|
|
2310
|
+
endIndex,
|
|
2311
|
+
paddingTop,
|
|
2312
|
+
paddingBottom,
|
|
2313
|
+
showWeekends: this.#opts.showWeekends ?? true,
|
|
2314
|
+
weekendDays: this.#weekendDays,
|
|
2315
|
+
specialDaysByDate: this.#specialDaysByDate,
|
|
2316
|
+
locale: this.#locale
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
#render = () => {
|
|
2320
|
+
this.#rafPending = false;
|
|
2321
|
+
const state = this.#computeState();
|
|
2322
|
+
renderTimeHeader(this.#rightHeader, state);
|
|
2323
|
+
renderLeftPane(this.#leftBody, state, {
|
|
2324
|
+
onToggle: (id) => {
|
|
2325
|
+
if (this.#expandedIds.has(id)) this.#expandedIds.delete(id);
|
|
2326
|
+
else this.#expandedIds.add(id);
|
|
2327
|
+
this.#scheduleRender();
|
|
2328
|
+
},
|
|
2329
|
+
onSelect: (id) => this.#cbs.onSelect?.(id),
|
|
2330
|
+
onRowClick: (payload) => {
|
|
2331
|
+
this.#handleGridClick(payload);
|
|
2332
|
+
},
|
|
2333
|
+
onTaskEditIntent: (payload) => this.#cbs.onTaskEditIntent?.(payload),
|
|
2334
|
+
onAdd: (id) => this.#cbs.onAdd?.({ parentId: id })
|
|
2335
|
+
}, this.#columns);
|
|
2336
|
+
renderRightPane(this.#rightPaneRefs, state, this.#cbs);
|
|
2337
|
+
};
|
|
2338
|
+
#scheduleRender() {
|
|
2339
|
+
if (this.#rafPending || this.#destroyed) return;
|
|
2340
|
+
this.#rafPending = true;
|
|
2341
|
+
this.#rafId = requestAnimationFrame(this.#render);
|
|
2342
|
+
}
|
|
2343
|
+
#applyTheme() {
|
|
2344
|
+
const theme = this.#opts.theme ?? "system";
|
|
2345
|
+
this.#container.dataset["theme"] = theme;
|
|
2346
|
+
}
|
|
2347
|
+
#assertAlive() {
|
|
2348
|
+
if (this.#destroyed) throw new GanttError("INSTANCE_DESTROYED", "Gantt instance was destroyed");
|
|
2349
|
+
}
|
|
2350
|
+
#buildDom() {
|
|
2351
|
+
const root = el("div");
|
|
2352
|
+
root.className = "gantt-root";
|
|
2353
|
+
css(root, {
|
|
2354
|
+
height: `${this.#height}px`,
|
|
2355
|
+
overflow: "hidden",
|
|
2356
|
+
display: "flex",
|
|
2357
|
+
flexDirection: "column",
|
|
2358
|
+
fontFamily: "var(--gantt-font)",
|
|
2359
|
+
background: "var(--gantt-bg)"
|
|
2360
|
+
});
|
|
2361
|
+
this.#root = root;
|
|
2362
|
+
const scrollEl = el("div");
|
|
2363
|
+
css(scrollEl, {
|
|
2364
|
+
flex: "1",
|
|
2365
|
+
overflow: "auto",
|
|
2366
|
+
position: "relative",
|
|
2367
|
+
display: "flex"
|
|
2368
|
+
});
|
|
2369
|
+
root.append(scrollEl);
|
|
2370
|
+
this.#scrollEl = scrollEl;
|
|
2371
|
+
const leftPane = el("div");
|
|
2372
|
+
leftPane.dataset["pane"] = "left";
|
|
2373
|
+
css(leftPane, {
|
|
2374
|
+
width: `${this.#leftPaneDefaultWidth}px`,
|
|
2375
|
+
flexShrink: "0",
|
|
2376
|
+
position: "sticky",
|
|
2377
|
+
left: "0",
|
|
2378
|
+
zIndex: "10",
|
|
2379
|
+
background: "var(--gantt-bg)",
|
|
2380
|
+
borderRight: "1px solid var(--gantt-border)"
|
|
2381
|
+
});
|
|
2382
|
+
this.#leftPane = leftPane;
|
|
2383
|
+
const leftHeader = el("div");
|
|
2384
|
+
css(leftHeader, {
|
|
2385
|
+
position: "sticky",
|
|
2386
|
+
top: "0",
|
|
2387
|
+
zIndex: "11",
|
|
2388
|
+
background: "var(--gantt-header-bg)"
|
|
2389
|
+
});
|
|
2390
|
+
const headerEl = buildLeftPaneHeader(this.#columns);
|
|
2391
|
+
leftHeader.append(headerEl);
|
|
2392
|
+
leftPane.append(leftHeader);
|
|
2393
|
+
const leftBody = el("div");
|
|
2394
|
+
leftPane.append(leftBody);
|
|
2395
|
+
this.#leftBody = leftBody;
|
|
2396
|
+
this.#columnResizeCleanup = setupColumnResize(headerEl, leftBody, this.#columns, (updated) => {
|
|
2397
|
+
this.#cbs.onGridColumnsChange?.(updated);
|
|
2398
|
+
});
|
|
2399
|
+
scrollEl.append(leftPane);
|
|
2400
|
+
const rightPane = el("div");
|
|
2401
|
+
rightPane.dataset["pane"] = "right";
|
|
2402
|
+
css(rightPane, {
|
|
2403
|
+
flexShrink: "0",
|
|
2404
|
+
position: "relative",
|
|
2405
|
+
minWidth: `${this.#timelineMinWidth}px`
|
|
2406
|
+
});
|
|
2407
|
+
this.#rightPane = rightPane;
|
|
2408
|
+
const rightHeader = el("div");
|
|
2409
|
+
css(rightHeader, {
|
|
2410
|
+
position: "sticky",
|
|
2411
|
+
top: "0",
|
|
2412
|
+
zIndex: "9",
|
|
2413
|
+
background: "var(--gantt-header-bg)"
|
|
2414
|
+
});
|
|
2415
|
+
rightPane.append(rightHeader);
|
|
2416
|
+
this.#rightHeader = rightHeader;
|
|
2417
|
+
this.#rightPaneRefs = createRightPaneRefs();
|
|
2418
|
+
rightPane.append(this.#rightPaneRefs.scrollContainer);
|
|
2419
|
+
scrollEl.append(rightPane);
|
|
2420
|
+
const splitterHandle = el("div");
|
|
2421
|
+
splitterHandle.className = "gantt-splitter-handle";
|
|
2422
|
+
css(splitterHandle, {
|
|
2423
|
+
position: "absolute",
|
|
2424
|
+
right: "0",
|
|
2425
|
+
top: "0",
|
|
2426
|
+
bottom: "0",
|
|
2427
|
+
width: "4px",
|
|
2428
|
+
cursor: "col-resize",
|
|
2429
|
+
zIndex: "20"
|
|
2430
|
+
});
|
|
2431
|
+
leftPane.append(splitterHandle);
|
|
2432
|
+
attachSplitter(splitterHandle, leftPane, this.#container, this.#timelineMinWidth, (finalWidth) => {
|
|
2433
|
+
this.#userSplitWidth = finalWidth;
|
|
2434
|
+
this.#cbs.onLeftPaneWidthChange?.(finalWidth);
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
#wireEvents() {
|
|
2438
|
+
this.#rightPaneRefs.absoluteLayer.addEventListener("click", (event) => {
|
|
2439
|
+
if (event.target.closest(".gantt-bar, .gantt-milestone, .gantt-resize-handle")) return;
|
|
2440
|
+
this.#cbs.onSelect?.(null);
|
|
2441
|
+
});
|
|
2442
|
+
this.#root.addEventListener("keydown", (event) => {
|
|
2443
|
+
if (event.key === "Escape" && this.#selectedId !== null) {
|
|
2444
|
+
event.preventDefault();
|
|
2445
|
+
this.#cbs.onSelect?.(null);
|
|
2446
|
+
}
|
|
2447
|
+
});
|
|
2448
|
+
this.#scrollEl.addEventListener("scroll", this.#onScroll);
|
|
2449
|
+
}
|
|
2450
|
+
#setupResizeObserver() {
|
|
2451
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
2452
|
+
this.#resizeObserver = new ResizeObserver(() => {
|
|
2453
|
+
this.#applyResponsivePaneStyles();
|
|
2454
|
+
});
|
|
2455
|
+
this.#resizeObserver.observe(this.#container);
|
|
2456
|
+
} else window.addEventListener("resize", this.#applyResponsivePaneStyles);
|
|
2457
|
+
}
|
|
2458
|
+
};
|
|
2459
|
+
//#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 };
|
|
2461
|
+
|
|
2462
|
+
//# sourceMappingURL=index.mjs.map
|