gantt-renderer 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +4 -3
- package/dist/index.d.mts +350 -95
- package/dist/index.mjs +1557 -599
- package/dist/index.mjs.map +1 -1
- package/dist/styles/gantt.css +27 -2
- package/package.json +90 -91
package/dist/index.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
//#region src/
|
|
2
|
+
//#region src/lib/validation/schemas.ts
|
|
3
3
|
const LinkTypeSchema = z.enum([
|
|
4
4
|
"FS",
|
|
5
5
|
"SS",
|
|
6
6
|
"FF",
|
|
7
7
|
"SF"
|
|
8
8
|
]);
|
|
9
|
-
const
|
|
9
|
+
const TaskKindSchema = z.enum([
|
|
10
10
|
"task",
|
|
11
11
|
"project",
|
|
12
12
|
"milestone"
|
|
@@ -14,49 +14,157 @@ const TaskTypeSchema = z.enum([
|
|
|
14
14
|
const SpecialDayKindSchema = z.enum(["holiday", "custom"]);
|
|
15
15
|
const SpecialDaySchema = z.object({
|
|
16
16
|
/** ISO date: YYYY-MM-DD */
|
|
17
|
-
date: z.string().regex(/^\d{4}-\d{2}-\d{2}
|
|
17
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/u, "Expected YYYY-MM-DD"),
|
|
18
18
|
kind: SpecialDayKindSchema,
|
|
19
19
|
label: z.string().min(1).optional(),
|
|
20
20
|
className: z.string().min(1).optional()
|
|
21
21
|
});
|
|
22
|
-
const
|
|
22
|
+
const taskBase = {
|
|
23
|
+
/** Unique positive integer identifier for the task. */
|
|
23
24
|
id: z.number().int().positive(),
|
|
25
|
+
/** Display name / label of the task. */
|
|
24
26
|
text: z.string().min(1),
|
|
25
27
|
/** ISO date: YYYY-MM-DD */
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
duration: z.number().int().min(0),
|
|
28
|
+
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/u, "Expected YYYY-MM-DD"),
|
|
29
|
+
/** Optional id of the parent task. When set, this task is a child in the hierarchy. */
|
|
29
30
|
parent: z.number().int().positive().optional(),
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
/** Optional CSS color value for the task bar. Overrides the default color assignment. */
|
|
32
|
+
color: z.string().optional(),
|
|
33
|
+
/** Optional arbitrary metadata for consumer use. Preserved in the parsed output. */
|
|
34
|
+
data: z.record(z.string(), z.unknown()).optional()
|
|
35
|
+
};
|
|
36
|
+
/** @internal */
|
|
37
|
+
const TaskLeafSchema = z.object({
|
|
38
|
+
...taskBase,
|
|
39
|
+
kind: z.literal("task"),
|
|
40
|
+
/** Duration in hours. Must be positive; use `kind: 'milestone'` for zero-duration points. */
|
|
41
|
+
durationHours: z.number().int().positive(),
|
|
42
|
+
/** 0–100 completion percentage (integer). */
|
|
43
|
+
percentComplete: z.number().int().min(0).max(100).default(0)
|
|
44
|
+
});
|
|
45
|
+
/** @internal */
|
|
46
|
+
const TaskProjectSchema = z.object({
|
|
47
|
+
...taskBase,
|
|
48
|
+
kind: z.literal("project"),
|
|
49
|
+
/** Duration in hours. Must be positive; use `kind: 'milestone'` for zero-duration points. */
|
|
50
|
+
durationHours: z.number().int().positive(),
|
|
51
|
+
/** 0–100 completion percentage (integer). */
|
|
52
|
+
percentComplete: z.number().int().min(0).max(100).default(0),
|
|
53
|
+
/**
|
|
54
|
+
* Initial expanded state for tree hierarchy.
|
|
55
|
+
* When `false`, children of this task are hidden on initial render.
|
|
56
|
+
*
|
|
57
|
+
* @default true
|
|
58
|
+
*/
|
|
59
|
+
open: z.boolean().default(true)
|
|
36
60
|
});
|
|
61
|
+
/** @internal */
|
|
62
|
+
const TaskMilestoneSchema = z.object({
|
|
63
|
+
...taskBase,
|
|
64
|
+
kind: z.literal("milestone")
|
|
65
|
+
});
|
|
66
|
+
const TaskSchema = z.discriminatedUnion("kind", [
|
|
67
|
+
TaskLeafSchema,
|
|
68
|
+
TaskProjectSchema,
|
|
69
|
+
TaskMilestoneSchema
|
|
70
|
+
]);
|
|
37
71
|
const LinkSchema = z.object({
|
|
72
|
+
/** Unique positive integer identifier for the dependency link. */
|
|
38
73
|
id: z.number().int().positive(),
|
|
74
|
+
/** The `id` of the predecessor task (the task that drives the dependency). */
|
|
39
75
|
source: z.number().int().positive(),
|
|
76
|
+
/** The `id` of the successor task (the task that depends on the predecessor). */
|
|
40
77
|
target: z.number().int().positive(),
|
|
41
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Dependency type.
|
|
80
|
+
*
|
|
81
|
+
* - `'FS'` — Finish-to-start: successor starts after predecessor finishes.
|
|
82
|
+
* - `'SS'` — Start-to-start: successor starts at the same time as predecessor.
|
|
83
|
+
* - `'FF'` — Finish-to-finish: successor finishes at the same time as predecessor.
|
|
84
|
+
* - `'SF'` — Start-to-finish: successor finishes after predecessor starts.
|
|
85
|
+
*
|
|
86
|
+
* @default 'FS'
|
|
87
|
+
*/
|
|
88
|
+
type: LinkTypeSchema.default("FS"),
|
|
89
|
+
/** Optional arbitrary metadata for consumer use. Preserved in the parsed output. */
|
|
90
|
+
data: z.record(z.string(), z.unknown()).optional()
|
|
91
|
+
}).refine((l) => l.source !== l.target, {
|
|
92
|
+
message: "A link cannot connect a task to itself",
|
|
93
|
+
path: ["target"]
|
|
42
94
|
});
|
|
43
95
|
const GanttInputSchema = z.object({
|
|
96
|
+
/** Array of task objects. At least one task is required. */
|
|
44
97
|
tasks: z.array(TaskSchema).min(1),
|
|
98
|
+
/** Optional array of dependency link objects. Defaults to empty array. */
|
|
45
99
|
links: z.array(LinkSchema).default([])
|
|
100
|
+
}).superRefine((data, ctx) => {
|
|
101
|
+
const taskIds = /* @__PURE__ */ new Set();
|
|
102
|
+
for (let i = 0; i < data.tasks.length; i++) {
|
|
103
|
+
const task = data.tasks[i];
|
|
104
|
+
if (task !== void 0) {
|
|
105
|
+
if (taskIds.has(task.id)) ctx.addIssue({
|
|
106
|
+
code: "custom",
|
|
107
|
+
message: `Duplicate task id: ${task.id}`,
|
|
108
|
+
path: [
|
|
109
|
+
"tasks",
|
|
110
|
+
i,
|
|
111
|
+
"id"
|
|
112
|
+
]
|
|
113
|
+
});
|
|
114
|
+
taskIds.add(task.id);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const linkIds = /* @__PURE__ */ new Set();
|
|
118
|
+
for (let i = 0; i < data.links.length; i++) {
|
|
119
|
+
const link = data.links[i];
|
|
120
|
+
if (link !== void 0) {
|
|
121
|
+
if (linkIds.has(link.id)) ctx.addIssue({
|
|
122
|
+
code: "custom",
|
|
123
|
+
message: `Duplicate link id: ${link.id}`,
|
|
124
|
+
path: [
|
|
125
|
+
"links",
|
|
126
|
+
i,
|
|
127
|
+
"id"
|
|
128
|
+
]
|
|
129
|
+
});
|
|
130
|
+
linkIds.add(link.id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const pairKeys = /* @__PURE__ */ new Set();
|
|
134
|
+
for (let i = 0; i < data.links.length; i++) {
|
|
135
|
+
const link = data.links[i];
|
|
136
|
+
if (link !== void 0) {
|
|
137
|
+
const key = `${link.source}:${link.target}`;
|
|
138
|
+
if (pairKeys.has(key)) ctx.addIssue({
|
|
139
|
+
code: "custom",
|
|
140
|
+
message: `Duplicate link pair: source=${link.source} target=${link.target}`,
|
|
141
|
+
path: ["links", i]
|
|
142
|
+
});
|
|
143
|
+
pairKeys.add(key);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
46
146
|
});
|
|
47
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* Parses raw external data.
|
|
149
|
+
*
|
|
150
|
+
* @param raw - The unvalidated input from the consumer.
|
|
151
|
+
* @returns The parsed and validated {@link GanttInput}.
|
|
152
|
+
* @throws {import('zod').ZodError} On schema validation failure.
|
|
153
|
+
*/
|
|
48
154
|
function parseGanttInput(raw) {
|
|
49
155
|
return GanttInputSchema.parse(raw);
|
|
50
156
|
}
|
|
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
157
|
//#endregion
|
|
57
|
-
//#region src/
|
|
158
|
+
//#region src/lib/errors.ts
|
|
159
|
+
/**
|
|
160
|
+
* Domain-specific error with a machine-readable {@link GanttErrorCode}.
|
|
161
|
+
*/
|
|
58
162
|
var GanttError = class extends Error {
|
|
59
163
|
code;
|
|
164
|
+
/**
|
|
165
|
+
* @param code - A machine-readable {@link GanttErrorCode} categorising the error.
|
|
166
|
+
* @param message - A human-readable description.
|
|
167
|
+
*/
|
|
60
168
|
constructor(code, message) {
|
|
61
169
|
super(message);
|
|
62
170
|
this.name = "GanttError";
|
|
@@ -64,20 +172,67 @@ var GanttError = class extends Error {
|
|
|
64
172
|
}
|
|
65
173
|
};
|
|
66
174
|
//#endregion
|
|
67
|
-
//#region src/
|
|
175
|
+
//#region src/lib/domain/tree.ts
|
|
176
|
+
function detectParentCycles(tasks) {
|
|
177
|
+
const adj = /* @__PURE__ */ new Map();
|
|
178
|
+
for (const task of tasks) {
|
|
179
|
+
adj.set(task.id, []);
|
|
180
|
+
if (task.parent !== void 0) {
|
|
181
|
+
const parents = adj.get(task.parent);
|
|
182
|
+
if (parents !== void 0) parents.push(task.id);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
186
|
+
const color = /* @__PURE__ */ new Map();
|
|
187
|
+
const parent = /* @__PURE__ */ new Map();
|
|
188
|
+
for (const id of adj.keys()) color.set(id, WHITE);
|
|
189
|
+
const dfs = (u) => {
|
|
190
|
+
color.set(u, GRAY);
|
|
191
|
+
for (const v of adj.get(u) ?? []) {
|
|
192
|
+
const vc = color.get(v) ?? WHITE;
|
|
193
|
+
if (vc === GRAY) {
|
|
194
|
+
const path = [v, u];
|
|
195
|
+
let cur = u;
|
|
196
|
+
while (cur !== v) {
|
|
197
|
+
const p = parent.get(cur);
|
|
198
|
+
if (p === void 0) break;
|
|
199
|
+
path.push(p);
|
|
200
|
+
cur = p;
|
|
201
|
+
}
|
|
202
|
+
throw new GanttError("PARENT_CYCLE", `Parent cycle detected: ${[...path].reverse().join(" -> ")}`);
|
|
203
|
+
}
|
|
204
|
+
if (vc === WHITE) {
|
|
205
|
+
parent.set(v, u);
|
|
206
|
+
dfs(v);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
color.set(u, BLACK);
|
|
210
|
+
};
|
|
211
|
+
for (const id of adj.keys()) if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
|
|
212
|
+
}
|
|
68
213
|
/**
|
|
69
214
|
* Builds a typed tree from a flat task array.
|
|
70
215
|
* Order of tasks[] is irrelevant — parents need not precede children.
|
|
71
|
-
*
|
|
216
|
+
*
|
|
217
|
+
* @param tasks - The flat array of tasks to convert into a tree.
|
|
218
|
+
* @returns Root-level {@link TaskNode} instances with populated `children`.
|
|
219
|
+
* @throws {GanttError} When a task references a `parent` id that does not exist,
|
|
220
|
+
* when a parent cycle is detected, or when a `parent` points to a
|
|
221
|
+
* milestone or leaf task.
|
|
72
222
|
*/
|
|
73
223
|
function buildTaskTree(tasks) {
|
|
74
224
|
const map = /* @__PURE__ */ new Map();
|
|
75
225
|
const roots = [];
|
|
226
|
+
for (const task of tasks) if (task.parent !== void 0) {
|
|
227
|
+
const parentTask = tasks.find((t) => t.id === task.parent);
|
|
228
|
+
if (parentTask !== void 0 && (parentTask.kind === "milestone" || parentTask.kind === "task")) throw new GanttError("PARENT_REFERENCE", `Task id=${task.id} cannot have parent id=${task.parent} of kind '${parentTask.kind}'`);
|
|
229
|
+
}
|
|
76
230
|
for (const task of tasks) map.set(task.id, {
|
|
77
231
|
...task,
|
|
78
232
|
children: [],
|
|
79
233
|
depth: 0
|
|
80
234
|
});
|
|
235
|
+
detectParentCycles(tasks);
|
|
81
236
|
for (const task of tasks) {
|
|
82
237
|
const node = map.get(task.id);
|
|
83
238
|
if (node === void 0) continue;
|
|
@@ -97,7 +252,11 @@ function buildTaskTree(tasks) {
|
|
|
97
252
|
}
|
|
98
253
|
/**
|
|
99
254
|
* Flattens a tree into a visible row list.
|
|
100
|
-
* A node's children are included only when its id is in expandedIds
|
|
255
|
+
* A node's children are included only when its id is in `expandedIds`.
|
|
256
|
+
*
|
|
257
|
+
* @param roots - The root-level {@link TaskNode} instances of the tree.
|
|
258
|
+
* @param expandedIds - Set of task IDs whose children should be rendered.
|
|
259
|
+
* @returns A depth-first flattened array of visible {@link TaskNode} items.
|
|
101
260
|
*/
|
|
102
261
|
function flattenTree(roots, expandedIds) {
|
|
103
262
|
const rows = [];
|
|
@@ -108,16 +267,23 @@ function flattenTree(roots, expandedIds) {
|
|
|
108
267
|
for (const root of roots) walk(root);
|
|
109
268
|
return rows;
|
|
110
269
|
}
|
|
111
|
-
/**
|
|
270
|
+
/**
|
|
271
|
+
* Returns `true` when a node has children in the tree.
|
|
272
|
+
*
|
|
273
|
+
* @param node - The {@link TaskNode} to inspect.
|
|
274
|
+
* @returns `true` if `node.children.length > 0`.
|
|
275
|
+
*/
|
|
112
276
|
function isParent(node) {
|
|
113
277
|
return node.children.length > 0;
|
|
114
278
|
}
|
|
115
279
|
//#endregion
|
|
116
|
-
//#region src/
|
|
280
|
+
//#region src/lib/domain/dependencies.ts
|
|
117
281
|
/**
|
|
118
282
|
* Detects circular dependencies in the link graph using DFS tri-colour marking.
|
|
119
|
-
*
|
|
120
|
-
*
|
|
283
|
+
*
|
|
284
|
+
* @param tasks - The task list (used to build the vertex set).
|
|
285
|
+
* @param links - The dependency links defining the directed edges.
|
|
286
|
+
* @throws {GanttError} When a cycle is detected, with a human-readable cycle path.
|
|
121
287
|
*/
|
|
122
288
|
function detectCycles(tasks, links) {
|
|
123
289
|
const adj = /* @__PURE__ */ new Map();
|
|
@@ -155,88 +321,34 @@ function detectCycles(tasks, links) {
|
|
|
155
321
|
for (const id of adj.keys()) if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
|
|
156
322
|
}
|
|
157
323
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
324
|
+
* Validates that every link references existing task IDs and that no
|
|
325
|
+
* duplicate (source, target) pairs exist.
|
|
326
|
+
*
|
|
327
|
+
* @param tasks - The task list (used as the reference set of valid IDs).
|
|
328
|
+
* @param links - The dependency links to validate.
|
|
329
|
+
* @throws {GanttError} When any link references a non-existent source or target task,
|
|
330
|
+
* when a non-FS link connects to/from a milestone, or when duplicate
|
|
331
|
+
* (source, target) pairs exist.
|
|
160
332
|
*/
|
|
161
333
|
function validateLinkRefs(tasks, links) {
|
|
162
334
|
const ids = new Set(tasks.map((t) => t.id));
|
|
335
|
+
const taskById = new Map(tasks.map((t) => [t.id, t]));
|
|
336
|
+
const pairKeys = /* @__PURE__ */ new Set();
|
|
163
337
|
for (const link of links) {
|
|
164
338
|
if (!ids.has(link.source)) throw new GanttError("LINK_REFERENCE", `Link id=${link.id}: source=${link.source} not found`);
|
|
165
339
|
if (!ids.has(link.target)) throw new GanttError("LINK_REFERENCE", `Link id=${link.id}: target=${link.target} not found`);
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
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);
|
|
340
|
+
const pairKey = `${link.source}:${link.target}`;
|
|
341
|
+
if (pairKeys.has(pairKey)) throw new GanttError("DUPLICATE_LINK_PAIR", `Link id=${link.id}: duplicate pair source=${link.source} target=${link.target}`);
|
|
342
|
+
pairKeys.add(pairKey);
|
|
343
|
+
if (link.type !== "FS") {
|
|
344
|
+
const sourceTask = taskById.get(link.source);
|
|
345
|
+
const targetTask = taskById.get(link.target);
|
|
346
|
+
if (sourceTask?.kind === "milestone" || targetTask?.kind === "milestone") throw new GanttError("MILESTONE_LINK_TYPE", `Link id=${link.id}: non-FS type '${link.type}' not allowed when connected to a milestone`);
|
|
216
347
|
}
|
|
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
348
|
}
|
|
238
|
-
return 1;
|
|
239
349
|
}
|
|
350
|
+
//#endregion
|
|
351
|
+
//#region src/lib/locale.ts
|
|
240
352
|
const WEEK_START_REGION = {
|
|
241
353
|
US: 0,
|
|
242
354
|
CA: 0,
|
|
@@ -289,24 +401,6 @@ const WEEK_START_LANG = {
|
|
|
289
401
|
ar: 6,
|
|
290
402
|
fa: 6
|
|
291
403
|
};
|
|
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
404
|
const WEEK_NUMBERING_REGION = {
|
|
311
405
|
US: "us",
|
|
312
406
|
CA: "us",
|
|
@@ -330,28 +424,6 @@ const WEEK_NUMBERING_REGION = {
|
|
|
330
424
|
AU: "us",
|
|
331
425
|
NZ: "us"
|
|
332
426
|
};
|
|
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
427
|
const WEEKEND_REGION = {
|
|
356
428
|
AE: [5, 6],
|
|
357
429
|
AF: [4, 5],
|
|
@@ -385,19 +457,127 @@ const WEEKEND_REGION = {
|
|
|
385
457
|
SO: [5],
|
|
386
458
|
MY: [5, 0]
|
|
387
459
|
};
|
|
460
|
+
const EN_US_LABELS = {
|
|
461
|
+
ariaTask: "Task {0}",
|
|
462
|
+
ariaMilestone: "Milestone {0}",
|
|
463
|
+
addSubtaskTitle: "Add subtask",
|
|
464
|
+
columnTaskName: "Task name",
|
|
465
|
+
columnStartDate: "Start time",
|
|
466
|
+
columnDuration: "Duration",
|
|
467
|
+
columnQuarter: "Q"
|
|
468
|
+
};
|
|
469
|
+
const CHART_LOCALE_EN_US = {
|
|
470
|
+
code: "en-US",
|
|
471
|
+
labels: EN_US_LABELS,
|
|
472
|
+
weekStartsOn: 0,
|
|
473
|
+
weekNumbering: "iso",
|
|
474
|
+
weekendDays: [0, 6]
|
|
475
|
+
};
|
|
476
|
+
function tryGetWeekInfo(code) {
|
|
477
|
+
try {
|
|
478
|
+
if (typeof Intl !== "undefined" && typeof Intl.Locale === "function") {
|
|
479
|
+
const locale = new Intl.Locale(code);
|
|
480
|
+
const fn = locale.getWeekInfo;
|
|
481
|
+
if (typeof fn === "function") return fn.call(locale);
|
|
482
|
+
}
|
|
483
|
+
} catch {}
|
|
484
|
+
}
|
|
388
485
|
/**
|
|
389
|
-
*
|
|
486
|
+
* Derives the first day of week (0=Sun, 1=Mon, 6=Sat) from a BCP 47 code.
|
|
487
|
+
* Uses `Intl.Locale.getWeekInfo()` where available (Chromium, Safari 15.4+),
|
|
488
|
+
* with a CLDR-based fallback table for Firefox and older runtimes.
|
|
390
489
|
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
* - `'simple'`: `Math.ceil(dayOfYear / 7)`.
|
|
490
|
+
* @param code - A BCP 47 language tag (e.g. `'en-US'`, `'de-DE'`).
|
|
491
|
+
* @returns The first day of the week: `0` (Sunday), `1` (Monday), or `6` (Saturday).
|
|
394
492
|
*/
|
|
395
|
-
function
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
493
|
+
function deriveWeekStartsOn(code) {
|
|
494
|
+
const primary = code.split("-")[0]?.toLowerCase() ?? "en";
|
|
495
|
+
const region = code.split("-")[1]?.toUpperCase();
|
|
496
|
+
if (region !== void 0) {
|
|
497
|
+
const fromRegion = WEEK_START_REGION[region];
|
|
498
|
+
if (fromRegion !== void 0) return fromRegion;
|
|
499
|
+
}
|
|
500
|
+
const fromLang = WEEK_START_LANG[primary];
|
|
501
|
+
if (fromLang !== void 0) return fromLang;
|
|
502
|
+
const info = tryGetWeekInfo(code);
|
|
503
|
+
if (info !== void 0) {
|
|
504
|
+
const day = info.firstDay;
|
|
505
|
+
return day === 7 ? 0 : day;
|
|
506
|
+
}
|
|
507
|
+
return 1;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Derives the week numbering scheme from a BCP 47 code.
|
|
511
|
+
* Europe and ISO-aligned regions default to `'iso'`; Americas and others to `'us'`.
|
|
512
|
+
*
|
|
513
|
+
* @param code - A BCP 47 language tag (e.g. `'en-US'`, `'de-DE'`).
|
|
514
|
+
* @returns The week numbering scheme: `'iso'`, `'us'`, or `'simple'`.
|
|
515
|
+
*/
|
|
516
|
+
function deriveWeekNumbering(code) {
|
|
517
|
+
const region = code.split("-")[1]?.toUpperCase();
|
|
518
|
+
if (region !== void 0) {
|
|
519
|
+
const fromRegion = WEEK_NUMBERING_REGION[region];
|
|
520
|
+
if (fromRegion !== void 0) return fromRegion;
|
|
521
|
+
if (region in WEEK_START_REGION) return "us";
|
|
522
|
+
}
|
|
523
|
+
const info = tryGetWeekInfo(code);
|
|
524
|
+
if (info !== void 0) {
|
|
525
|
+
if (info.minimalDays >= 4 && info.firstDay === 1) return "iso";
|
|
526
|
+
return "us";
|
|
527
|
+
}
|
|
528
|
+
return "iso";
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Derives weekend days (0=Sun … 6=Sat) from a BCP 47 code.
|
|
532
|
+
* Uses `Intl.Locale.getWeekInfo()` where available, with a CLDR-based fallback table.
|
|
533
|
+
*
|
|
534
|
+
* @param code - A BCP 47 language tag (e.g. `'en-US'`, `'de-DE'`).
|
|
535
|
+
* @returns An array of weekend day indices (sorted ascending).
|
|
536
|
+
*/
|
|
537
|
+
function deriveWeekendDays(code) {
|
|
538
|
+
const region = code.split("-")[1]?.toUpperCase();
|
|
539
|
+
if (region !== void 0) {
|
|
540
|
+
const fromRegion = WEEKEND_REGION[region];
|
|
541
|
+
if (fromRegion !== void 0) {
|
|
542
|
+
const days = [...fromRegion];
|
|
543
|
+
days.sort((a, b) => a - b);
|
|
544
|
+
return days;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
const info = tryGetWeekInfo(code);
|
|
548
|
+
if (info !== void 0) {
|
|
549
|
+
const days = info.weekend.map((d) => d === 7 ? 0 : d);
|
|
550
|
+
days.sort((a, b) => a - b);
|
|
551
|
+
return days;
|
|
552
|
+
}
|
|
553
|
+
return [0, 6];
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Resolves a {@link ChartLocale} from either a full `ChartLocale` object or a BCP 47 string.
|
|
557
|
+
* When given a string, derives `weekStartsOn`, `weekNumbering`, and `weekendDays` from CLDR conventions.
|
|
558
|
+
*
|
|
559
|
+
* @param raw - A {@link ChartLocale} object, a BCP 47 language tag string, or `undefined`.
|
|
560
|
+
* @returns A fully resolved {@link ChartLocale} with defaults applied.
|
|
561
|
+
*/
|
|
562
|
+
function resolveChartLocale(raw) {
|
|
563
|
+
if (raw === void 0) return CHART_LOCALE_EN_US;
|
|
564
|
+
if (typeof raw !== "string") {
|
|
565
|
+
const locale = {
|
|
566
|
+
code: raw.code,
|
|
567
|
+
weekStartsOn: raw.weekStartsOn ?? deriveWeekStartsOn(raw.code),
|
|
568
|
+
weekNumbering: raw.weekNumbering ?? deriveWeekNumbering(raw.code),
|
|
569
|
+
weekendDays: raw.weekendDays ?? deriveWeekendDays(raw.code)
|
|
570
|
+
};
|
|
571
|
+
if (raw.labels !== void 0) locale.labels = raw.labels;
|
|
572
|
+
return locale;
|
|
400
573
|
}
|
|
574
|
+
const code = raw;
|
|
575
|
+
return {
|
|
576
|
+
code,
|
|
577
|
+
weekStartsOn: deriveWeekStartsOn(code),
|
|
578
|
+
weekNumbering: deriveWeekNumbering(code),
|
|
579
|
+
weekendDays: deriveWeekendDays(code)
|
|
580
|
+
};
|
|
401
581
|
}
|
|
402
582
|
function isoWeek(date) {
|
|
403
583
|
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
@@ -422,33 +602,107 @@ function simpleWeek(date) {
|
|
|
422
602
|
return Math.ceil((dayOfYear + 1) / 7);
|
|
423
603
|
}
|
|
424
604
|
/**
|
|
605
|
+
* Formats a week number according to the specified scheme.
|
|
606
|
+
*
|
|
607
|
+
* - `'iso'`: ISO 8601 (week 1 contains the first Thursday; Monday start).
|
|
608
|
+
* - `'us'`: Week 1 contains January 1; Sunday start.
|
|
609
|
+
* - `'simple'`: `Math.ceil(dayOfYear / 7)`.
|
|
610
|
+
*
|
|
611
|
+
* @param date - The date to compute the week number for.
|
|
612
|
+
* @param scheme - The week numbering scheme: `'iso'`, `'us'`, or `'simple'`.
|
|
613
|
+
* @returns The week number as a positive integer.
|
|
614
|
+
*/
|
|
615
|
+
function formatWeekNumber(date, scheme) {
|
|
616
|
+
switch (scheme) {
|
|
617
|
+
case "iso": return isoWeek(date);
|
|
618
|
+
case "us": return usWeek(date);
|
|
619
|
+
case "simple": return simpleWeek(date);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
425
623
|
* Formats a label template by replacing `{0}` with the given argument.
|
|
624
|
+
*
|
|
625
|
+
* @param template - The template string containing `{0}` as placeholder.
|
|
626
|
+
* @param arg - The value to substitute for `{0}`.
|
|
627
|
+
* @returns The formatted string with the placeholder replaced.
|
|
426
628
|
*/
|
|
427
629
|
function formatLabel(template, arg) {
|
|
428
630
|
return template.replaceAll("{0}", arg);
|
|
429
631
|
}
|
|
430
632
|
//#endregion
|
|
431
|
-
//#region src/
|
|
432
|
-
/**
|
|
633
|
+
//#region src/lib/domain/dateMath.ts
|
|
634
|
+
/**
|
|
635
|
+
* Parses `YYYY-MM-DD` → UTC midnight `Date`.
|
|
636
|
+
*
|
|
637
|
+
* @param dateStr - An ISO-8601 date string in `YYYY-MM-DD` format.
|
|
638
|
+
* @returns A `Date` representing UTC midnight of the given date.
|
|
639
|
+
* @throws {Error} When `dateStr` does not represent a valid date.
|
|
640
|
+
*/
|
|
433
641
|
function parseDate(dateStr) {
|
|
434
642
|
const d = /* @__PURE__ */ new Date(`${dateStr}T00:00:00.000Z`);
|
|
435
643
|
if (isNaN(d.getTime())) throw new Error(`Invalid date: "${dateStr}"`);
|
|
436
644
|
return d;
|
|
437
645
|
}
|
|
438
|
-
/**
|
|
646
|
+
/**
|
|
647
|
+
* Returns `date + n` days using exact millisecond arithmetic.
|
|
648
|
+
*
|
|
649
|
+
* @param date - The base date.
|
|
650
|
+
* @param days - Number of days to add (may be negative).
|
|
651
|
+
* @returns A new `Date` offset by the given number of days.
|
|
652
|
+
*/
|
|
439
653
|
function addDays(date, days) {
|
|
440
654
|
return new Date(date.getTime() + days * 864e5);
|
|
441
655
|
}
|
|
442
|
-
/**
|
|
656
|
+
/**
|
|
657
|
+
* Returns `date + n` hours using exact millisecond arithmetic.
|
|
658
|
+
*
|
|
659
|
+
* @param date - The base date.
|
|
660
|
+
* @param hours - Number of hours to add (may be negative).
|
|
661
|
+
* @returns A new `Date` offset by the given number of hours.
|
|
662
|
+
*/
|
|
663
|
+
function addHours(date, hours) {
|
|
664
|
+
return new Date(date.getTime() + hours * 36e5);
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Difference in days (float). Positive when `b > a`.
|
|
668
|
+
*
|
|
669
|
+
* @param a - The earlier date.
|
|
670
|
+
* @param b - The later date.
|
|
671
|
+
* @returns The fractional number of days between the two dates.
|
|
672
|
+
*/
|
|
443
673
|
function diffDays(a, b) {
|
|
444
674
|
return (b.getTime() - a.getTime()) / 864e5;
|
|
445
675
|
}
|
|
446
|
-
/**
|
|
676
|
+
/**
|
|
677
|
+
* Difference in hours (float). Positive when `b > a`.
|
|
678
|
+
*
|
|
679
|
+
* @param a - The earlier date.
|
|
680
|
+
* @param b - The later date.
|
|
681
|
+
* @returns The fractional number of hours between the two dates.
|
|
682
|
+
*/
|
|
683
|
+
function diffHours(a, b) {
|
|
684
|
+
return (b.getTime() - a.getTime()) / 36e5;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Returns the UTC start-of-day for the given date.
|
|
688
|
+
*
|
|
689
|
+
* @param date - Any `Date`.
|
|
690
|
+
* @returns A new `Date` set to UTC midnight of the same calendar date.
|
|
691
|
+
*/
|
|
447
692
|
function startOfDay(date) {
|
|
448
693
|
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
449
694
|
}
|
|
695
|
+
function resolveQuarterLabel(locale) {
|
|
696
|
+
if (locale.labels?.columnQuarter !== void 0) return locale.labels.columnQuarter;
|
|
697
|
+
return EN_US_LABELS.columnQuarter;
|
|
698
|
+
}
|
|
450
699
|
/**
|
|
451
|
-
* Formats a Date for the time-header label given the active scale.
|
|
700
|
+
* Formats a `Date` for the time-header label given the active scale.
|
|
701
|
+
*
|
|
702
|
+
* @param date - The date to format.
|
|
703
|
+
* @param scale - The active {@link TimeScale} determining the label granularity.
|
|
704
|
+
* @param locale - The {@link ChartLocale} used for formatting.
|
|
705
|
+
* @returns A human-readable header label string.
|
|
452
706
|
*/
|
|
453
707
|
function formatHeaderLabel(date, scale, locale) {
|
|
454
708
|
const { code, weekNumbering: weekNumScheme = "iso" } = locale;
|
|
@@ -473,7 +727,12 @@ function formatHeaderLabel(date, scale, locale) {
|
|
|
473
727
|
}
|
|
474
728
|
/**
|
|
475
729
|
* Returns the upper-level (month/year) label for a given scale column.
|
|
476
|
-
* Used in the top header row.
|
|
730
|
+
* Used in the top header row of the timeline.
|
|
731
|
+
*
|
|
732
|
+
* @param date - The date to format.
|
|
733
|
+
* @param scale - The active {@link TimeScale}. Determines how the upper label is computed.
|
|
734
|
+
* @param locale - The {@link ChartLocale} used for formatting.
|
|
735
|
+
* @returns A human-readable upper-level header label string.
|
|
477
736
|
*/
|
|
478
737
|
function formatUpperLabel(date, scale, locale) {
|
|
479
738
|
const { code } = locale;
|
|
@@ -495,11 +754,13 @@ function formatUpperLabel(date, scale, locale) {
|
|
|
495
754
|
case "year": return `${date.getUTCFullYear()}`;
|
|
496
755
|
}
|
|
497
756
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
757
|
+
/**
|
|
758
|
+
* Formats a `YYYY-MM-DD` string for display in the grid.
|
|
759
|
+
*
|
|
760
|
+
* @param dateStr - An ISO-8601 date string in `YYYY-MM-DD` format.
|
|
761
|
+
* @param locale - The {@link ChartLocale} used for locale-aware formatting.
|
|
762
|
+
* @returns A locale-formatted date string.
|
|
763
|
+
*/
|
|
503
764
|
function formatDisplayDate(dateStr, locale) {
|
|
504
765
|
return parseDate(dateStr).toLocaleDateString(locale.code, {
|
|
505
766
|
year: "numeric",
|
|
@@ -509,7 +770,7 @@ function formatDisplayDate(dateStr, locale) {
|
|
|
509
770
|
});
|
|
510
771
|
}
|
|
511
772
|
//#endregion
|
|
512
|
-
//#region src/
|
|
773
|
+
//#region src/lib/timeline/scale.ts
|
|
513
774
|
const H = 36e5;
|
|
514
775
|
const D = 864e5;
|
|
515
776
|
const SCALE_CONFIGS = {
|
|
@@ -547,8 +808,12 @@ const SCALE_CONFIGS = {
|
|
|
547
808
|
/**
|
|
548
809
|
* Snaps a date to the column boundary for the provided scale.
|
|
549
810
|
* All operations use UTC semantics.
|
|
550
|
-
* The week boundary respects the optional weekStartsOn override (0=Sun, 1=Mon, 6=Sat).
|
|
551
|
-
*
|
|
811
|
+
* The week boundary respects the optional `weekStartsOn` override (0=Sun, 1=Mon, 6=Sat).
|
|
812
|
+
*
|
|
813
|
+
* @param date - The date to snap.
|
|
814
|
+
* @param scale - The target {@link TimeScale}.
|
|
815
|
+
* @param weekStartsOn - First day of the week (`0`-Sun, `1`-Mon, `6`-Sat). Defaults to `1` (Monday).
|
|
816
|
+
* @returns A new `Date` snapped to the column boundary.
|
|
552
817
|
*/
|
|
553
818
|
function snapToScaleBoundary(date, scale, weekStartsOn = 1) {
|
|
554
819
|
switch (scale) {
|
|
@@ -572,6 +837,10 @@ function snapToScaleBoundary(date, scale, weekStartsOn = 1) {
|
|
|
572
837
|
/**
|
|
573
838
|
* Returns the next column boundary from a boundary-aligned date.
|
|
574
839
|
* Month/quarter/year use true calendar stepping (not fixed-day approximations).
|
|
840
|
+
*
|
|
841
|
+
* @param date - A boundary-aligned date.
|
|
842
|
+
* @param scale - The target {@link TimeScale}.
|
|
843
|
+
* @returns The next boundary date.
|
|
575
844
|
*/
|
|
576
845
|
function nextScaleBoundary(date, scale) {
|
|
577
846
|
switch (scale) {
|
|
@@ -584,17 +853,21 @@ function nextScaleBoundary(date, scale) {
|
|
|
584
853
|
}
|
|
585
854
|
}
|
|
586
855
|
//#endregion
|
|
587
|
-
//#region src/
|
|
856
|
+
//#region src/lib/timeline/pixelMapper.ts
|
|
588
857
|
/**
|
|
589
858
|
* Creates a stateless pixel mapper for the given scale and viewport start.
|
|
590
859
|
* All conversions are O(1) arithmetic — safe to call in tight loops.
|
|
860
|
+
*
|
|
861
|
+
* @param scale - The active {@link TimeScale}.
|
|
862
|
+
* @param viewportStart - The leftmost date visible in the viewport.
|
|
863
|
+
* @returns A {@link PixelMapper} configured for the given viewport.
|
|
591
864
|
*/
|
|
592
865
|
function createPixelMapper(scale, viewportStart) {
|
|
593
866
|
const { columnWidth, msPerColumn } = SCALE_CONFIGS[scale];
|
|
594
867
|
const originMs = viewportStart.getTime();
|
|
595
868
|
const pxPerMs = columnWidth / msPerColumn;
|
|
596
869
|
const msPerPx = msPerColumn / columnWidth;
|
|
597
|
-
const
|
|
870
|
+
const msPerHour = 36e5;
|
|
598
871
|
return {
|
|
599
872
|
originMs,
|
|
600
873
|
columnWidth,
|
|
@@ -604,16 +877,16 @@ function createPixelMapper(scale, viewportStart) {
|
|
|
604
877
|
toDate(x) {
|
|
605
878
|
return new Date(originMs + x * msPerPx);
|
|
606
879
|
},
|
|
607
|
-
durationToWidth(
|
|
608
|
-
return
|
|
880
|
+
durationToWidth(hours) {
|
|
881
|
+
return hours * msPerHour * pxPerMs;
|
|
609
882
|
},
|
|
610
883
|
widthToDuration(px) {
|
|
611
|
-
return px * msPerPx /
|
|
884
|
+
return px * msPerPx / msPerHour;
|
|
612
885
|
}
|
|
613
886
|
};
|
|
614
887
|
}
|
|
615
888
|
//#endregion
|
|
616
|
-
//#region src/
|
|
889
|
+
//#region src/lib/timeline/layoutEngine.ts
|
|
617
890
|
const DENSITY = {
|
|
618
891
|
rowHeight: 44,
|
|
619
892
|
barHeight: 28,
|
|
@@ -628,18 +901,21 @@ const MILESTONE_HALF = MILESTONE_SIZE / 2;
|
|
|
628
901
|
/**
|
|
629
902
|
* Computes pixel-space layout for all visible task rows.
|
|
630
903
|
* Returns a map keyed by task id for O(1) lookup during link routing.
|
|
904
|
+
*
|
|
905
|
+
* @param rows - The flattened, visible {@link TaskNode} rows.
|
|
906
|
+
* @param mapper - The {@link PixelMapper} for coordinate conversion.
|
|
907
|
+
* @returns A `Map` from task ID to its computed {@link BarLayout}.
|
|
631
908
|
*/
|
|
632
909
|
function computeLayout(rows, mapper) {
|
|
633
910
|
const result = /* @__PURE__ */ new Map();
|
|
634
911
|
for (let i = 0; i < rows.length; i++) {
|
|
635
912
|
const task = rows[i];
|
|
636
913
|
if (task === void 0) continue;
|
|
637
|
-
const start = parseDate(task.
|
|
914
|
+
const start = parseDate(task.startDate);
|
|
638
915
|
const x = mapper.toX(start);
|
|
639
916
|
const y = i * ROW_HEIGHT + BAR_Y_OFFSET;
|
|
640
917
|
const centerY = i * ROW_HEIGHT + ROW_HEIGHT / 2;
|
|
641
|
-
|
|
642
|
-
if (type === "milestone") {
|
|
918
|
+
if (task.kind === "milestone") {
|
|
643
919
|
result.set(task.id, {
|
|
644
920
|
taskId: task.id,
|
|
645
921
|
x,
|
|
@@ -647,15 +923,15 @@ function computeLayout(rows, mapper) {
|
|
|
647
923
|
width: 0,
|
|
648
924
|
height: BAR_HEIGHT,
|
|
649
925
|
progressWidth: 0,
|
|
650
|
-
|
|
926
|
+
kind: "milestone",
|
|
651
927
|
rowIndex: i,
|
|
652
928
|
centerX: x,
|
|
653
929
|
centerY
|
|
654
930
|
});
|
|
655
931
|
continue;
|
|
656
932
|
}
|
|
657
|
-
const width = Math.max(mapper.durationToWidth(task.
|
|
658
|
-
const progressWidth = width * Math.min(1, Math.max(0, task.
|
|
933
|
+
const width = Math.max(mapper.durationToWidth(task.durationHours), 4);
|
|
934
|
+
const progressWidth = width * Math.min(1, Math.max(0, (task.percentComplete ?? 0) / 100));
|
|
659
935
|
result.set(task.id, {
|
|
660
936
|
taskId: task.id,
|
|
661
937
|
x,
|
|
@@ -663,7 +939,7 @@ function computeLayout(rows, mapper) {
|
|
|
663
939
|
width,
|
|
664
940
|
height: BAR_HEIGHT,
|
|
665
941
|
progressWidth,
|
|
666
|
-
|
|
942
|
+
kind: task.kind,
|
|
667
943
|
rowIndex: i,
|
|
668
944
|
centerX: x + width / 2,
|
|
669
945
|
centerY
|
|
@@ -673,36 +949,334 @@ function computeLayout(rows, mapper) {
|
|
|
673
949
|
}
|
|
674
950
|
/**
|
|
675
951
|
* Computes the total pixel height of all rows.
|
|
952
|
+
*
|
|
953
|
+
* @param rowCount - The number of visible rows.
|
|
954
|
+
* @returns The total pixel height (`rowCount * ROW_HEIGHT`).
|
|
676
955
|
*/
|
|
677
956
|
function totalContentHeight(rowCount) {
|
|
678
957
|
return rowCount * ROW_HEIGHT;
|
|
679
958
|
}
|
|
680
959
|
/**
|
|
681
960
|
* Derives viewport bounds from task data with padding.
|
|
682
|
-
*
|
|
961
|
+
*
|
|
962
|
+
* @param tasks - The task nodes to derive bounds from.
|
|
963
|
+
* @param paddingHours - Extra hours added before the earliest start and after the latest end. Defaults to `48`.
|
|
964
|
+
* @returns A tuple `[start, end]` of UTC midnight `Date` instances.
|
|
683
965
|
*/
|
|
684
|
-
function deriveViewport(tasks,
|
|
966
|
+
function deriveViewport(tasks, paddingHours = 48) {
|
|
685
967
|
if (tasks.length === 0) {
|
|
686
968
|
const now = /* @__PURE__ */ new Date();
|
|
687
|
-
return [now,
|
|
969
|
+
return [now, addHours(now, 720)];
|
|
688
970
|
}
|
|
689
971
|
let minMs = Infinity;
|
|
690
972
|
let maxMs = -Infinity;
|
|
691
973
|
for (const task of tasks) {
|
|
692
|
-
const start = parseDate(task.
|
|
693
|
-
const end = addDays(start, task.duration);
|
|
974
|
+
const start = parseDate(task.startDate);
|
|
694
975
|
if (start.getTime() < minMs) minMs = start.getTime();
|
|
695
|
-
if (
|
|
976
|
+
if (task.kind !== "milestone") {
|
|
977
|
+
const end = addHours(start, task.durationHours);
|
|
978
|
+
if (end.getTime() > maxMs) maxMs = end.getTime();
|
|
979
|
+
} else if (start.getTime() > maxMs) maxMs = start.getTime();
|
|
980
|
+
}
|
|
981
|
+
return [addHours(new Date(minMs), -paddingHours), addHours(new Date(maxMs), paddingHours)];
|
|
982
|
+
}
|
|
983
|
+
//#endregion
|
|
984
|
+
//#region src/lib/rendering/linkRouter.ts
|
|
985
|
+
/** px gap before/after bar for routing clearance and arrow approach */
|
|
986
|
+
const TURN_MARGIN = 24;
|
|
987
|
+
/** px vertical offset below the bar row for same-row loop detours */
|
|
988
|
+
const SAME_ROW_DETOUR = 24;
|
|
989
|
+
/** Segments shorter than this (px) are collapsed before stroking */
|
|
990
|
+
const STUB_THRESHOLD = 2;
|
|
991
|
+
/**
|
|
992
|
+
* Removes consecutive points whose Euclidean distance is below {@link STUB_THRESHOLD}.
|
|
993
|
+
* The first point is always kept. This prevents near‑zero‑length segments from
|
|
994
|
+
* appearing as visible stubs near the arrowhead.
|
|
995
|
+
*
|
|
996
|
+
* @param points - The ordered vertex list.
|
|
997
|
+
* @returns A filtered copy, guaranteed to contain at least the first point.
|
|
998
|
+
*/
|
|
999
|
+
function collapseStubs(points) {
|
|
1000
|
+
const out = [];
|
|
1001
|
+
for (const pt of points) {
|
|
1002
|
+
const last = out.at(-1);
|
|
1003
|
+
if (last === void 0 || Math.hypot(pt.x - last.x, pt.y - last.y) >= STUB_THRESHOLD) out.push(pt);
|
|
1004
|
+
}
|
|
1005
|
+
return out;
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* True when the dependency arrow enters the target on its **left** edge (FS, SS).
|
|
1009
|
+
* False when the arrow enters on the target's **right** edge (FF, SF).
|
|
1010
|
+
*
|
|
1011
|
+
* The arrowhead uses SVG `orient="auto"` so it rotates to match the direction
|
|
1012
|
+
* of the **last** path segment. Therefore:
|
|
1013
|
+
*
|
|
1014
|
+
* - Left-entry → last segment must travel **RIGHT** (penultimate.x < tx).
|
|
1015
|
+
* - Right-entry → last segment must travel **LEFT** (penultimate.x > tx).
|
|
1016
|
+
*
|
|
1017
|
+
* @param type - The link type.
|
|
1018
|
+
* @returns `true` for FS / SS, `false` for FF / SF.
|
|
1019
|
+
*/
|
|
1020
|
+
function isLeftEntry(type) {
|
|
1021
|
+
return type === "FS" || type === "SS";
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* True when the link exits the source bar on its **right** edge (FS, FF).
|
|
1025
|
+
* False when it exits on the **left** edge (SS, SF).
|
|
1026
|
+
*
|
|
1027
|
+
* The first step after the source anchor should move **away** from the bar,
|
|
1028
|
+
* **not** into it. This means:
|
|
1029
|
+
*
|
|
1030
|
+
* - Exit‑right → first horizontal segment goes RIGHT (+TURN_MARGIN).
|
|
1031
|
+
* - Exit‑left → first horizontal segment goes LEFT (-TURN_MARGIN).
|
|
1032
|
+
*
|
|
1033
|
+
* @param type - The link type.
|
|
1034
|
+
* @returns `true` for FS / FF, `false` for SS / SF.
|
|
1035
|
+
*/
|
|
1036
|
+
function isExitRight(type) {
|
|
1037
|
+
return type === "FS" || type === "FF";
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Computes anchor points for the given link type.
|
|
1041
|
+
*
|
|
1042
|
+
* | Type | Source anchor (`sx`) | Target anchor (`tx`) |
|
|
1043
|
+
* |------|-----------------------------|-------------------------------|
|
|
1044
|
+
* | FS | right edge of source | left edge of target |
|
|
1045
|
+
* | SS | left edge of source | left edge of target |
|
|
1046
|
+
* | FF | right edge of source | right edge of target |
|
|
1047
|
+
* | SF | left edge of source | right edge of target |
|
|
1048
|
+
*
|
|
1049
|
+
* Milestone offsets are applied automatically: ± {@link MILESTONE_HALF} replaces
|
|
1050
|
+
* ± width for zero‑width milestones.
|
|
1051
|
+
*
|
|
1052
|
+
* @param type - The link type determining start/end anchor points.
|
|
1053
|
+
* @param src - The source bar layout.
|
|
1054
|
+
* @param tgt - The target bar layout.
|
|
1055
|
+
* @returns Anchor x coordinates `{sx, tx}`.
|
|
1056
|
+
* @throws {Error} if the link type is not handled (exhaustiveness guard).
|
|
1057
|
+
*/
|
|
1058
|
+
function getAnchors(type, src, tgt) {
|
|
1059
|
+
const srcRight = src.kind === "milestone" ? src.x + MILESTONE_HALF : src.x + src.width;
|
|
1060
|
+
const srcLeft = src.kind === "milestone" ? src.x - MILESTONE_HALF : src.x;
|
|
1061
|
+
const tgtRight = tgt.kind === "milestone" ? tgt.x + MILESTONE_HALF : tgt.x + tgt.width;
|
|
1062
|
+
const tgtLeft = tgt.kind === "milestone" ? tgt.x - MILESTONE_HALF : tgt.x;
|
|
1063
|
+
switch (type) {
|
|
1064
|
+
case "FS": return {
|
|
1065
|
+
sx: srcRight,
|
|
1066
|
+
tx: tgtLeft
|
|
1067
|
+
};
|
|
1068
|
+
case "SS": return {
|
|
1069
|
+
sx: srcLeft,
|
|
1070
|
+
tx: tgtLeft
|
|
1071
|
+
};
|
|
1072
|
+
case "FF": return {
|
|
1073
|
+
sx: srcRight,
|
|
1074
|
+
tx: tgtRight
|
|
1075
|
+
};
|
|
1076
|
+
case "SF": return {
|
|
1077
|
+
sx: srcLeft,
|
|
1078
|
+
tx: tgtRight
|
|
1079
|
+
};
|
|
1080
|
+
default: throw new Error(`Unhandled link type: ${String(type)}`);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Routes a link whose source and target rows are within 1 px of each other.
|
|
1085
|
+
*
|
|
1086
|
+
* **Direct‑line optimisation**
|
|
1087
|
+
* A plain horizontal segment is only used when it is non‑degenerate (`sx ≠ tx`)
|
|
1088
|
+
* AND the arrowhead direction is visually correct:
|
|
1089
|
+
*
|
|
1090
|
+
* | Entry side | Condition | Arrow direction |
|
|
1091
|
+
* |-----------|-----------|----------------|
|
|
1092
|
+
* | left | `sx < tx` | → RIGHT ✓ |
|
|
1093
|
+
* | right | `sx > tx` | ← LEFT ✓ |
|
|
1094
|
+
*
|
|
1095
|
+
* Otherwise a 6‑vertex detour is drawn so that the last segment approaches
|
|
1096
|
+
* the target from the correct side. By default the detour goes **below** the
|
|
1097
|
+
* bars; pass `above = true` when headroom is insufficient below.
|
|
1098
|
+
*
|
|
1099
|
+
* @param sx - Source anchor x.
|
|
1100
|
+
* @param sy - Source row center y.
|
|
1101
|
+
* @param tx - Target anchor x.
|
|
1102
|
+
* @param ty - Target row center y.
|
|
1103
|
+
* @param leftEntry - Whether the link enters the target on its left edge.
|
|
1104
|
+
* @param exitRight - Whether the link exits the source on its right edge.
|
|
1105
|
+
* @param above - Route the detour above the bar row instead of below (default `false`).
|
|
1106
|
+
* @returns An ordered array of {@link Point} vertices.
|
|
1107
|
+
*/
|
|
1108
|
+
function routeSameRow(sx, sy, tx, ty, leftEntry, exitRight, above = false) {
|
|
1109
|
+
if (Math.abs(sx - tx) >= STUB_THRESHOLD) {
|
|
1110
|
+
if (leftEntry && sx < tx || !leftEntry && sx > tx) return [{
|
|
1111
|
+
x: sx,
|
|
1112
|
+
y: sy
|
|
1113
|
+
}, {
|
|
1114
|
+
x: tx,
|
|
1115
|
+
y: ty
|
|
1116
|
+
}];
|
|
696
1117
|
}
|
|
697
|
-
|
|
1118
|
+
const exitDir = exitRight ? TURN_MARGIN : -TURN_MARGIN;
|
|
1119
|
+
const detourY = sy + (above ? -SAME_ROW_DETOUR : SAME_ROW_DETOUR);
|
|
1120
|
+
const approachX = leftEntry ? tx - TURN_MARGIN : tx + TURN_MARGIN;
|
|
1121
|
+
return [
|
|
1122
|
+
{
|
|
1123
|
+
x: sx,
|
|
1124
|
+
y: sy
|
|
1125
|
+
},
|
|
1126
|
+
{
|
|
1127
|
+
x: sx + exitDir,
|
|
1128
|
+
y: sy
|
|
1129
|
+
},
|
|
1130
|
+
{
|
|
1131
|
+
x: sx + exitDir,
|
|
1132
|
+
y: detourY
|
|
1133
|
+
},
|
|
1134
|
+
{
|
|
1135
|
+
x: approachX,
|
|
1136
|
+
y: detourY
|
|
1137
|
+
},
|
|
1138
|
+
{
|
|
1139
|
+
x: approachX,
|
|
1140
|
+
y: ty
|
|
1141
|
+
},
|
|
1142
|
+
{
|
|
1143
|
+
x: tx,
|
|
1144
|
+
y: ty
|
|
1145
|
+
}
|
|
1146
|
+
];
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Routes a link between **different** rows using an orthogonal path.
|
|
1150
|
+
*
|
|
1151
|
+
* 1. Step **away** from the source bar to the crossover x (`crossX`).
|
|
1152
|
+
* 2. Travel **vertically** to the midpoint between rows (`midY`).
|
|
1153
|
+
* 3. Travel **horizontally** to the approach point on the correct side
|
|
1154
|
+
* of the target.
|
|
1155
|
+
* 4. Travel **vertically** to the target row (`ty`).
|
|
1156
|
+
* 5. Final segment to the target entry point (`tx`).
|
|
1157
|
+
*
|
|
1158
|
+
* The crossover x is clamped so the path never doubles back past both bars.
|
|
1159
|
+
* When exit and entry are on the **same** side (SS / FF) the exit-side step
|
|
1160
|
+
* is limited to the approach x, avoiding a wide U‑shape.
|
|
1161
|
+
*
|
|
1162
|
+
* The approach point is chosen so the last segment travels in the arrow
|
|
1163
|
+
* direction demanded by the entry side:
|
|
1164
|
+
*
|
|
1165
|
+
* - Left‑entry (FS, SS): approach from the **left** → `tx - TURN_MARGIN`
|
|
1166
|
+
* Last segment goes RIGHT.
|
|
1167
|
+
* - Right‑entry (FF, SF): approach from the **right** → `tx + TURN_MARGIN`
|
|
1168
|
+
* Last segment goes LEFT.
|
|
1169
|
+
*
|
|
1170
|
+
* left‑entry (FS / SS) right‑entry (FF / SF)
|
|
1171
|
+
* ───────────────────── ─────────────────────
|
|
1172
|
+
* sx ●────────────────► sx ●────────────────►
|
|
1173
|
+
* exitDir exitDir
|
|
1174
|
+
* │ │
|
|
1175
|
+
* │ midY │ midY
|
|
1176
|
+
* ▼ ════════════════════► ▼ ════════════════════►
|
|
1177
|
+
* │ │
|
|
1178
|
+
* │ │
|
|
1179
|
+
* ▼ approachFromLeft ▼ approachFromRight
|
|
1180
|
+
* ●────────────────► ◄────────────────●
|
|
1181
|
+
* tx tx
|
|
1182
|
+
*
|
|
1183
|
+
* @param sx - Source anchor x.
|
|
1184
|
+
* @param sy - Source row center y.
|
|
1185
|
+
* @param tx - Target anchor x.
|
|
1186
|
+
* @param ty - Target row center y.
|
|
1187
|
+
* @param leftEntry - Whether the link enters the target on its left edge.
|
|
1188
|
+
* @param exitRight - Whether the link exits the source on its right edge.
|
|
1189
|
+
* @returns An ordered array of {@link Point} vertices.
|
|
1190
|
+
*/
|
|
1191
|
+
function routeMultiRow(sx, sy, tx, ty, leftEntry, exitRight) {
|
|
1192
|
+
const midY = Math.round(Math.abs(sy - ty) / ROW_HEIGHT) % 2 === 0 ? (sy + ty) / 2 + ROW_HEIGHT / 2 : (sy + ty) / 2;
|
|
1193
|
+
const approachX = leftEntry ? tx - TURN_MARGIN : tx + TURN_MARGIN;
|
|
1194
|
+
const crossX = exitRight ? Math.max(sx + TURN_MARGIN, approachX) : Math.min(sx - TURN_MARGIN, approachX);
|
|
1195
|
+
return [
|
|
1196
|
+
{
|
|
1197
|
+
x: sx,
|
|
1198
|
+
y: sy
|
|
1199
|
+
},
|
|
1200
|
+
{
|
|
1201
|
+
x: crossX,
|
|
1202
|
+
y: sy
|
|
1203
|
+
},
|
|
1204
|
+
{
|
|
1205
|
+
x: crossX,
|
|
1206
|
+
y: midY
|
|
1207
|
+
},
|
|
1208
|
+
{
|
|
1209
|
+
x: approachX,
|
|
1210
|
+
y: midY
|
|
1211
|
+
},
|
|
1212
|
+
{
|
|
1213
|
+
x: approachX,
|
|
1214
|
+
y: ty
|
|
1215
|
+
},
|
|
1216
|
+
{
|
|
1217
|
+
x: tx,
|
|
1218
|
+
y: ty
|
|
1219
|
+
}
|
|
1220
|
+
];
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Produces the vertex list for an orthogonal connector between source and target.
|
|
1224
|
+
*
|
|
1225
|
+
* ## Anchor points (sx / tx)
|
|
1226
|
+
*
|
|
1227
|
+
* | Type | Source anchor (`sx`) | Target anchor (`tx`) |
|
|
1228
|
+
* |------|-----------------------------|-------------------------------|
|
|
1229
|
+
* | FS | right edge of source | left edge of target |
|
|
1230
|
+
* | SS | left edge of source | left edge of target |
|
|
1231
|
+
* | FF | right edge of source | right edge of target |
|
|
1232
|
+
* | SF | left edge of source | right edge of target |
|
|
1233
|
+
*
|
|
1234
|
+
* Milestone offsets are applied automatically: ± {@link MILESTONE_HALF} replaces
|
|
1235
|
+
* ± width for zero‑width milestones.
|
|
1236
|
+
*
|
|
1237
|
+
* ## Routing strategy
|
|
1238
|
+
*
|
|
1239
|
+
* **Same row** (|sy − ty| < 1 px):
|
|
1240
|
+
* - Direct horizontal line when non‑degenerate **and** the arrow direction
|
|
1241
|
+
* naturally points **into** the target (see {@link routeSameRow}).
|
|
1242
|
+
* - Otherwise a 6‑vertex detour below the bars is drawn.
|
|
1243
|
+
*
|
|
1244
|
+
* **Different rows**: always a 6‑vertex orthogonal path that steps away from
|
|
1245
|
+
* the source, passes through the midpoint between rows, and approaches the
|
|
1246
|
+
* target from the correct side (see {@link routeMultiRow}).
|
|
1247
|
+
*
|
|
1248
|
+
* ## Arrowhead direction guarantee
|
|
1249
|
+
*
|
|
1250
|
+
* The SVG `marker-end` uses `orient="auto"`, so the arrow rotates to match
|
|
1251
|
+
* the last segment. This function ensures the last segment always travels
|
|
1252
|
+
* **into** the target on the semantically correct edge:
|
|
1253
|
+
*
|
|
1254
|
+
* | Entry side | Target edge | Last segment direction |
|
|
1255
|
+
* |-----------|-------------|-----------------------|
|
|
1256
|
+
* | left | left edge | → RIGHT |
|
|
1257
|
+
* | right | right edge | ← LEFT |
|
|
1258
|
+
*
|
|
1259
|
+
* @param type - The link type determining start/end anchor points.
|
|
1260
|
+
* @param src - The source bar layout.
|
|
1261
|
+
* @param tgt - The target bar layout.
|
|
1262
|
+
* @returns An ordered array of {@link Point} vertices.
|
|
1263
|
+
*/
|
|
1264
|
+
function route(type, src, tgt) {
|
|
1265
|
+
const { sx, tx } = getAnchors(type, src, tgt);
|
|
1266
|
+
const sy = src.centerY;
|
|
1267
|
+
const ty = tgt.centerY;
|
|
1268
|
+
const leftEntry = isLeftEntry(type);
|
|
1269
|
+
const exitRight = isExitRight(type);
|
|
1270
|
+
return collapseStubs(Math.abs(sy - ty) < 1 ? routeSameRow(sx, sy, tx, ty, leftEntry, exitRight) : routeMultiRow(sx, sy, tx, ty, leftEntry, exitRight));
|
|
698
1271
|
}
|
|
699
|
-
//#endregion
|
|
700
|
-
//#region src/gantt-chart/rendering/linkRouter.ts
|
|
701
|
-
const TURN_MARGIN = 12;
|
|
702
1272
|
/**
|
|
703
1273
|
* Computes orthogonal routing for all dependency links.
|
|
704
1274
|
* Links whose source or target is not in the layout map are skipped silently
|
|
705
1275
|
* (e.g. when the row is collapsed).
|
|
1276
|
+
*
|
|
1277
|
+
* @param links - The dependency links to route.
|
|
1278
|
+
* @param layouts - A map from task ID to its computed {@link BarLayout}.
|
|
1279
|
+
* @returns An array of {@link RoutedLink} objects with computed vertex paths.
|
|
706
1280
|
*/
|
|
707
1281
|
function routeLinks(links, layouts) {
|
|
708
1282
|
return links.map((link) => {
|
|
@@ -713,99 +1287,22 @@ function routeLinks(links, layouts) {
|
|
|
713
1287
|
linkId: link.id,
|
|
714
1288
|
sourceTaskId: link.source,
|
|
715
1289
|
targetTaskId: link.target,
|
|
1290
|
+
type: link.type,
|
|
716
1291
|
points: route(link.type, src, tgt)
|
|
717
1292
|
};
|
|
718
1293
|
}).filter((r) => r !== null);
|
|
719
1294
|
}
|
|
1295
|
+
//#endregion
|
|
1296
|
+
//#region src/lib/vanilla/dom/helpers.ts
|
|
720
1297
|
/**
|
|
721
|
-
*
|
|
1298
|
+
* Batches style assignments; avoids repeated style recalculations.
|
|
722
1299
|
*
|
|
723
|
-
*
|
|
724
|
-
*
|
|
725
|
-
* SS = source.start → target.start
|
|
726
|
-
* FF = source.finish → target.finish
|
|
727
|
-
* SF = source.start → target.finish
|
|
1300
|
+
* @param elem - The target element.
|
|
1301
|
+
* @param styles - A partial CSS style declaration to apply.
|
|
728
1302
|
*/
|
|
729
|
-
function
|
|
730
|
-
|
|
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
|
-
];
|
|
1303
|
+
function css(elem, styles) {
|
|
1304
|
+
for (const [k, v] of Object.entries(styles)) elem.style[k] = v ?? "";
|
|
806
1305
|
}
|
|
807
|
-
//#endregion
|
|
808
|
-
//#region src/gantt-chart/vanilla/dom/helpers.ts
|
|
809
1306
|
function el(tag, props, ns) {
|
|
810
1307
|
const elem = ns ? document.createElementNS(ns, tag) : document.createElement(tag);
|
|
811
1308
|
if (props !== void 0) for (const [k, v] of Object.entries(props)) if (k === "style" && typeof v === "object" && v !== null) css(elem, v);
|
|
@@ -813,32 +1310,61 @@ function el(tag, props, ns) {
|
|
|
813
1310
|
else elem.setAttribute(k, String(v));
|
|
814
1311
|
return elem;
|
|
815
1312
|
}
|
|
816
|
-
/**
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1313
|
+
/**
|
|
1314
|
+
* Removes all child nodes from elem. Faster than `innerHTML = ''` for large subtrees.
|
|
1315
|
+
*
|
|
1316
|
+
* @param elem - The element to clear.
|
|
1317
|
+
*/
|
|
821
1318
|
function clearChildren(elem) {
|
|
822
1319
|
while (elem.firstChild !== null) elem.removeChild(elem.firstChild);
|
|
823
1320
|
}
|
|
824
|
-
/**
|
|
1321
|
+
/**
|
|
1322
|
+
* Appends all nodes from an array/fragment into parent in one pass.
|
|
1323
|
+
*
|
|
1324
|
+
* @param parent - The parent element.
|
|
1325
|
+
* @param children - The child elements or text nodes to append.
|
|
1326
|
+
*/
|
|
825
1327
|
function appendAll(parent, children) {
|
|
826
1328
|
const frag = document.createDocumentFragment();
|
|
827
1329
|
for (const c of children) frag.appendChild(c);
|
|
828
1330
|
parent.append(frag);
|
|
829
1331
|
}
|
|
830
|
-
/**
|
|
1332
|
+
/**
|
|
1333
|
+
* Sets multiple SVG attributes in one call.
|
|
1334
|
+
*
|
|
1335
|
+
* @param elem - The target SVG element.
|
|
1336
|
+
* @param attrs - Attributes to set (values are stringified).
|
|
1337
|
+
*/
|
|
831
1338
|
function setAttrs(elem, attrs) {
|
|
832
1339
|
for (const [k, v] of Object.entries(attrs)) elem.setAttribute(k, String(v));
|
|
833
1340
|
}
|
|
834
1341
|
//#endregion
|
|
835
|
-
//#region src/
|
|
1342
|
+
//#region src/lib/vanilla/dom/timeHeader.ts
|
|
1343
|
+
function specialDayKind(date, specialDaysByDate, showWeekends, weekendDays) {
|
|
1344
|
+
const dateKey = startOfDay(date).toISOString().slice(0, 10);
|
|
1345
|
+
const specialDay = specialDaysByDate.get(dateKey);
|
|
1346
|
+
if (specialDay !== void 0) return specialDay.kind;
|
|
1347
|
+
if (showWeekends && weekendDays.has(date.getUTCDay())) return "weekend";
|
|
1348
|
+
return null;
|
|
1349
|
+
}
|
|
1350
|
+
/**
|
|
1351
|
+
* Inline style helper local to this module.
|
|
1352
|
+
*
|
|
1353
|
+
* @param elem - The target element.
|
|
1354
|
+
* @param styles - A partial CSS style declaration to apply.
|
|
1355
|
+
*/
|
|
1356
|
+
function css_(elem, styles) {
|
|
1357
|
+
for (const [k, v] of Object.entries(styles)) elem.style[k] = v ?? "";
|
|
1358
|
+
}
|
|
836
1359
|
/**
|
|
837
1360
|
* Fully replaces the content of `container` with two header rows.
|
|
838
1361
|
* Called on scale change or viewport change only — not on scroll.
|
|
1362
|
+
*
|
|
1363
|
+
* @param container - The header container element to render into.
|
|
1364
|
+
* @param state - The current chart state.
|
|
839
1365
|
*/
|
|
840
1366
|
function renderTimeHeader(container, state) {
|
|
841
|
-
const { scale, viewportStart, viewportEnd, mapper, totalWidth, locale } = state;
|
|
1367
|
+
const { scale, viewportStart, viewportEnd, mapper, totalWidth, locale, showWeekends, weekendDays, specialDaysByDate } = state;
|
|
842
1368
|
const weekStartsOn = locale.weekStartsOn ?? 1;
|
|
843
1369
|
const upperCells = [];
|
|
844
1370
|
const lowerCells = [];
|
|
@@ -853,7 +1379,8 @@ function renderTimeHeader(container, state) {
|
|
|
853
1379
|
lowerCells.push({
|
|
854
1380
|
label: formatHeaderLabel(cur, scale, locale),
|
|
855
1381
|
x,
|
|
856
|
-
width: w
|
|
1382
|
+
width: w,
|
|
1383
|
+
date: new Date(cur)
|
|
857
1384
|
});
|
|
858
1385
|
const uLabel = formatUpperLabel(cur, scale, locale);
|
|
859
1386
|
if (uLabel !== prevUpperLabel) {
|
|
@@ -928,6 +1455,19 @@ function renderTimeHeader(container, state) {
|
|
|
928
1455
|
whiteSpace: "nowrap"
|
|
929
1456
|
});
|
|
930
1457
|
d.textContent = cell.label;
|
|
1458
|
+
if (scale === "day") {
|
|
1459
|
+
const kind = specialDayKind(cell.date, specialDaysByDate, showWeekends, weekendDays);
|
|
1460
|
+
if (kind !== null) {
|
|
1461
|
+
d.classList.add(`gantt-header-cell--${kind}`);
|
|
1462
|
+
const dateKey = startOfDay(cell.date).toISOString().slice(0, 10);
|
|
1463
|
+
d.dataset["date"] = dateKey;
|
|
1464
|
+
const specialDay = specialDaysByDate.get(dateKey);
|
|
1465
|
+
if (specialDay?.label !== void 0) {
|
|
1466
|
+
d.dataset["label"] = specialDay.label;
|
|
1467
|
+
d.title = specialDay.label;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
931
1471
|
return d;
|
|
932
1472
|
});
|
|
933
1473
|
appendAll(upperRow, upperNodes);
|
|
@@ -936,12 +1476,8 @@ function renderTimeHeader(container, state) {
|
|
|
936
1476
|
container.append(upperRow);
|
|
937
1477
|
container.append(lowerRow);
|
|
938
1478
|
}
|
|
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
1479
|
//#endregion
|
|
944
|
-
//#region src/
|
|
1480
|
+
//#region src/lib/vanilla/dom/gridColumns.ts
|
|
945
1481
|
const DEFAULT_GRID_COLUMNS = [
|
|
946
1482
|
{
|
|
947
1483
|
id: "name",
|
|
@@ -949,17 +1485,17 @@ const DEFAULT_GRID_COLUMNS = [
|
|
|
949
1485
|
width: "1fr"
|
|
950
1486
|
},
|
|
951
1487
|
{
|
|
952
|
-
id: "
|
|
1488
|
+
id: "startDate",
|
|
953
1489
|
header: "Start time",
|
|
954
1490
|
width: "90px",
|
|
955
|
-
field: "
|
|
1491
|
+
field: "startDate",
|
|
956
1492
|
format: (value, _task, _row, locale) => formatDisplayDate(String(value), locale)
|
|
957
1493
|
},
|
|
958
1494
|
{
|
|
959
|
-
id: "
|
|
1495
|
+
id: "durationHours",
|
|
960
1496
|
header: "Duration",
|
|
961
1497
|
width: "68px",
|
|
962
|
-
field: "
|
|
1498
|
+
field: "durationHours",
|
|
963
1499
|
format: (value) => value > 0 ? String(value) : "—"
|
|
964
1500
|
},
|
|
965
1501
|
{
|
|
@@ -970,27 +1506,30 @@ const DEFAULT_GRID_COLUMNS = [
|
|
|
970
1506
|
];
|
|
971
1507
|
/**
|
|
972
1508
|
* Returns a localized default grid column schema.
|
|
973
|
-
* Column headers use locale label overrides with EN_US_LABELS fallback.
|
|
1509
|
+
* Column headers use locale label overrides with `EN_US_LABELS` fallback.
|
|
1510
|
+
*
|
|
1511
|
+
* @param locale - The {@link ChartLocale} to derive column header labels from.
|
|
1512
|
+
* @returns An array of {@link GridColumn} objects.
|
|
974
1513
|
*/
|
|
975
1514
|
function gridColumnDefaults(locale) {
|
|
976
1515
|
return [
|
|
977
1516
|
{
|
|
978
1517
|
id: "name",
|
|
979
|
-
header: locale.labels?.
|
|
1518
|
+
header: locale.labels?.columnTaskName ?? EN_US_LABELS.columnTaskName,
|
|
980
1519
|
width: "1fr"
|
|
981
1520
|
},
|
|
982
1521
|
{
|
|
983
|
-
id: "
|
|
984
|
-
header: locale.labels?.
|
|
1522
|
+
id: "startDate",
|
|
1523
|
+
header: locale.labels?.columnStartDate ?? EN_US_LABELS.columnStartDate,
|
|
985
1524
|
width: "90px",
|
|
986
|
-
field: "
|
|
1525
|
+
field: "startDate",
|
|
987
1526
|
format: (value, _task, _row, loc) => formatDisplayDate(String(value), loc)
|
|
988
1527
|
},
|
|
989
1528
|
{
|
|
990
|
-
id: "
|
|
991
|
-
header: locale.labels?.
|
|
1529
|
+
id: "durationHours",
|
|
1530
|
+
header: locale.labels?.columnDuration ?? EN_US_LABELS.columnDuration,
|
|
992
1531
|
width: "68px",
|
|
993
|
-
field: "
|
|
1532
|
+
field: "durationHours",
|
|
994
1533
|
format: (value) => value > 0 ? String(value) : "—"
|
|
995
1534
|
},
|
|
996
1535
|
{
|
|
@@ -1000,15 +1539,27 @@ function gridColumnDefaults(locale) {
|
|
|
1000
1539
|
}
|
|
1001
1540
|
];
|
|
1002
1541
|
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Builds a CSS `grid-template-columns` value from a column schema.
|
|
1544
|
+
*
|
|
1545
|
+
* @param columns - The full column schema array (only visible columns are included).
|
|
1546
|
+
* @returns A space-separated CSS track list.
|
|
1547
|
+
*/
|
|
1003
1548
|
function gridTemplateColumns(columns) {
|
|
1004
1549
|
return columns.filter((c) => c.visible !== false).map((c) => c.width).join(" ");
|
|
1005
1550
|
}
|
|
1551
|
+
/**
|
|
1552
|
+
* Filters a column schema to only visible columns.
|
|
1553
|
+
*
|
|
1554
|
+
* @param columns - The full column schema array.
|
|
1555
|
+
* @returns A new array containing only columns where `visible` is not `false`.
|
|
1556
|
+
*/
|
|
1006
1557
|
function visibleColumns(columns) {
|
|
1007
1558
|
return columns.filter((c) => c.visible !== false);
|
|
1008
1559
|
}
|
|
1009
1560
|
const GRID_COLUMN_FR_MIN_WIDTH = 120;
|
|
1010
|
-
const PX_RE = /^(\d+(?:\.\d+)?)px
|
|
1011
|
-
const FR_RE = /^(\d+(?:\.\d+)?)fr
|
|
1561
|
+
const PX_RE = /^(\d+(?:\.\d+)?)px$/u;
|
|
1562
|
+
const FR_RE = /^(\d+(?:\.\d+)?)fr$/u;
|
|
1012
1563
|
function parseColumnMinWidth(width) {
|
|
1013
1564
|
const trimmed = width.trim();
|
|
1014
1565
|
const pxMatch = PX_RE.exec(trimmed);
|
|
@@ -1017,73 +1568,57 @@ function parseColumnMinWidth(width) {
|
|
|
1017
1568
|
if (frMatch) return parseFloat(frMatch[1] ?? "0") * 120;
|
|
1018
1569
|
return 0;
|
|
1019
1570
|
}
|
|
1571
|
+
/**
|
|
1572
|
+
* Computes the minimum natural pixel width of a grid column schema.
|
|
1573
|
+
*
|
|
1574
|
+
* @param columns - The full column schema array.
|
|
1575
|
+
* @returns The sum of minimum widths: `px` columns sum directly, `fr` units contribute
|
|
1576
|
+
* `GRID_COLUMN_FR_MIN_WIDTH` px each.
|
|
1577
|
+
*/
|
|
1020
1578
|
function gridNaturalWidth(columns) {
|
|
1021
1579
|
let total = 0;
|
|
1022
1580
|
for (const col of visibleColumns(columns)) total += parseColumnMinWidth(col.width);
|
|
1023
1581
|
return total;
|
|
1024
1582
|
}
|
|
1025
1583
|
//#endregion
|
|
1026
|
-
//#region src/
|
|
1584
|
+
//#region src/lib/vanilla/dom/leftPane.ts
|
|
1027
1585
|
const INDENT = 16;
|
|
1028
1586
|
const COLUMN_MIN_WIDTH = 30;
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1587
|
+
function toTask$1(node) {
|
|
1588
|
+
const base = {
|
|
1589
|
+
id: node.id,
|
|
1590
|
+
text: node.text,
|
|
1591
|
+
startDate: node.startDate,
|
|
1592
|
+
...node.parent === void 0 ? {} : { parent: node.parent },
|
|
1593
|
+
...node.color === void 0 ? {} : { color: node.color },
|
|
1594
|
+
...node.data === void 0 ? {} : { data: node.data }
|
|
1595
|
+
};
|
|
1596
|
+
switch (node.kind) {
|
|
1597
|
+
case "task": return {
|
|
1598
|
+
...base,
|
|
1599
|
+
kind: "task",
|
|
1600
|
+
durationHours: node.durationHours,
|
|
1601
|
+
percentComplete: node.percentComplete
|
|
1602
|
+
};
|
|
1603
|
+
case "project": return {
|
|
1604
|
+
...base,
|
|
1605
|
+
kind: "project",
|
|
1606
|
+
durationHours: node.durationHours,
|
|
1607
|
+
percentComplete: node.percentComplete,
|
|
1608
|
+
open: node.open
|
|
1609
|
+
};
|
|
1610
|
+
case "milestone": return {
|
|
1611
|
+
...base,
|
|
1612
|
+
kind: "milestone"
|
|
1613
|
+
};
|
|
1043
1614
|
}
|
|
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
1615
|
}
|
|
1082
|
-
function
|
|
1083
|
-
switch (
|
|
1084
|
-
case "
|
|
1085
|
-
case "
|
|
1086
|
-
|
|
1616
|
+
function getTaskField(task, field) {
|
|
1617
|
+
switch (field) {
|
|
1618
|
+
case "durationHours": return task.kind !== "milestone" ? task.durationHours : void 0;
|
|
1619
|
+
case "percentComplete": return task.kind !== "milestone" ? task.percentComplete : void 0;
|
|
1620
|
+
case "open": return task.kind === "project" ? task.open : void 0;
|
|
1621
|
+
default: return task[field];
|
|
1087
1622
|
}
|
|
1088
1623
|
}
|
|
1089
1624
|
function buildTreeNameCell(row, expandedIds, cbs) {
|
|
@@ -1128,7 +1663,7 @@ function buildTreeNameCell(row, expandedIds, cbs) {
|
|
|
1128
1663
|
const label = el("span");
|
|
1129
1664
|
css(label, {
|
|
1130
1665
|
fontSize: "var(--gantt-font-size-md)",
|
|
1131
|
-
fontWeight: row.
|
|
1666
|
+
fontWeight: row.kind === "project" ? "var(--gantt-font-weight-bold)" : "var(--gantt-font-weight-normal)",
|
|
1132
1667
|
color: "var(--gantt-text)",
|
|
1133
1668
|
overflow: "hidden",
|
|
1134
1669
|
textOverflow: "ellipsis",
|
|
@@ -1152,9 +1687,9 @@ function buildDataCell(row, column, locale) {
|
|
|
1152
1687
|
css(cell, styles);
|
|
1153
1688
|
const task = toTask$1(row);
|
|
1154
1689
|
if (column.field !== void 0) {
|
|
1155
|
-
const rawValue = task
|
|
1690
|
+
const rawValue = getTaskField(task, column.field);
|
|
1156
1691
|
if (column.format !== void 0) cell.textContent = column.format(rawValue, task, row, locale);
|
|
1157
|
-
else cell.textContent = rawValue !== null && rawValue !== void 0 ? String(rawValue) : "";
|
|
1692
|
+
else cell.textContent = rawValue !== null && rawValue !== void 0 ? typeof rawValue === "object" ? JSON.stringify(rawValue) : String(rawValue) : "";
|
|
1158
1693
|
}
|
|
1159
1694
|
return cell;
|
|
1160
1695
|
}
|
|
@@ -1162,7 +1697,7 @@ function buildAddButton(row, cbs, locale) {
|
|
|
1162
1697
|
const btn = el("button");
|
|
1163
1698
|
btn.className = "gantt-add-btn";
|
|
1164
1699
|
btn.textContent = "+";
|
|
1165
|
-
btn.title = locale.labels?.
|
|
1700
|
+
btn.title = locale.labels?.addSubtaskTitle ?? EN_US_LABELS.addSubtaskTitle;
|
|
1166
1701
|
css(btn, {
|
|
1167
1702
|
background: "none",
|
|
1168
1703
|
border: "none",
|
|
@@ -1173,24 +1708,83 @@ function buildAddButton(row, cbs, locale) {
|
|
|
1173
1708
|
});
|
|
1174
1709
|
btn.addEventListener("click", (event) => {
|
|
1175
1710
|
event.stopPropagation();
|
|
1176
|
-
cbs.
|
|
1711
|
+
cbs.onTaskAdd(row.id);
|
|
1177
1712
|
});
|
|
1178
1713
|
return btn;
|
|
1179
1714
|
}
|
|
1180
|
-
function
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1715
|
+
function buildCell(column, row, expandedIds, cbs, locale) {
|
|
1716
|
+
switch (column.id) {
|
|
1717
|
+
case "name": return buildTreeNameCell(row, expandedIds, cbs);
|
|
1718
|
+
case "actions": return buildAddButton(row, cbs, locale);
|
|
1719
|
+
default: return buildDataCell(row, column, locale);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
function buildRow(row, selectedId, expandedIds, cbs, columns, locale) {
|
|
1723
|
+
const selected = row.id === selectedId;
|
|
1724
|
+
const wrapper = el("div");
|
|
1725
|
+
wrapper.className = "gantt-row";
|
|
1726
|
+
css(wrapper, {
|
|
1727
|
+
display: "grid",
|
|
1728
|
+
gridTemplateColumns: gridTemplateColumns(columns),
|
|
1729
|
+
height: `${ROW_HEIGHT}px`,
|
|
1730
|
+
alignItems: "center",
|
|
1731
|
+
paddingLeft: "8px",
|
|
1732
|
+
background: selected ? "var(--gantt-row-selected)" : "var(--gantt-bg)",
|
|
1733
|
+
borderBottom: "1px solid var(--gantt-border)",
|
|
1734
|
+
cursor: "default",
|
|
1735
|
+
boxSizing: "border-box"
|
|
1736
|
+
});
|
|
1737
|
+
wrapper.tabIndex = 0;
|
|
1738
|
+
wrapper.setAttribute("role", "row");
|
|
1739
|
+
wrapper.setAttribute("aria-selected", String(selected));
|
|
1740
|
+
wrapper.dataset["taskId"] = String(row.id);
|
|
1741
|
+
wrapper.addEventListener("click", () => {
|
|
1742
|
+
const task = toTask$1(row);
|
|
1743
|
+
cbs.onRowClick({
|
|
1744
|
+
id: row.id,
|
|
1745
|
+
task
|
|
1746
|
+
});
|
|
1747
|
+
});
|
|
1748
|
+
wrapper.addEventListener("keydown", (event) => {
|
|
1749
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
1750
|
+
event.preventDefault();
|
|
1751
|
+
cbs.onTaskSelect(row.id);
|
|
1752
|
+
}
|
|
1753
|
+
});
|
|
1754
|
+
for (const column of visibleColumns(columns)) wrapper.append(buildCell(column, row, expandedIds, cbs, locale));
|
|
1755
|
+
return wrapper;
|
|
1756
|
+
}
|
|
1757
|
+
/**
|
|
1758
|
+
* Renders the left grid pane.
|
|
1759
|
+
*
|
|
1760
|
+
* @param container - The left pane body element to render into.
|
|
1761
|
+
* @param state - The current chart state.
|
|
1762
|
+
* @param cbs - The left pane callbacks.
|
|
1763
|
+
* @param columns - The grid column schema.
|
|
1764
|
+
*/
|
|
1765
|
+
function renderLeftPane(container, state, cbs, columns) {
|
|
1766
|
+
const { allRows, selectedId, expandedIds, startIndex, endIndex, paddingTop, paddingBottom, locale } = state;
|
|
1767
|
+
const frag = document.createDocumentFragment();
|
|
1768
|
+
if (paddingTop > 0) {
|
|
1769
|
+
const spacer = el("div");
|
|
1770
|
+
spacer.style.height = `${paddingTop}px`;
|
|
1771
|
+
frag.append(spacer);
|
|
1772
|
+
}
|
|
1773
|
+
for (const row of allRows.slice(startIndex, endIndex + 1)) frag.append(buildRow(row, selectedId, expandedIds, cbs, columns, locale));
|
|
1774
|
+
if (paddingBottom > 0) {
|
|
1775
|
+
const spacer = el("div");
|
|
1776
|
+
spacer.style.height = `${paddingBottom}px`;
|
|
1777
|
+
frag.append(spacer);
|
|
1778
|
+
}
|
|
1779
|
+
clearChildren(container);
|
|
1780
|
+
container.append(frag);
|
|
1192
1781
|
}
|
|
1193
|
-
/**
|
|
1782
|
+
/**
|
|
1783
|
+
* Builds the header row for the left pane.
|
|
1784
|
+
*
|
|
1785
|
+
* @param columns - The grid column schema.
|
|
1786
|
+
* @returns The header DOM element.
|
|
1787
|
+
*/
|
|
1194
1788
|
function buildLeftPaneHeader(columns) {
|
|
1195
1789
|
const header = el("div");
|
|
1196
1790
|
css(header, {
|
|
@@ -1246,8 +1840,13 @@ function buildLeftPaneHeader(columns) {
|
|
|
1246
1840
|
}
|
|
1247
1841
|
/**
|
|
1248
1842
|
* Wires up column resize interactions on header handles.
|
|
1249
|
-
* Must be called after the header is in the DOM (so getBoundingClientRect works).
|
|
1250
|
-
*
|
|
1843
|
+
* Must be called after the header is in the DOM (so `getBoundingClientRect` works).
|
|
1844
|
+
*
|
|
1845
|
+
* @param headerEl - The header element containing resize handles.
|
|
1846
|
+
* @param bodyEl - The body element whose rows share the column widths.
|
|
1847
|
+
* @param columns - The grid column schema (mutated in place on resize end).
|
|
1848
|
+
* @param onChange - Optional callback fired on drag end with updated columns.
|
|
1849
|
+
* @returns A cleanup function that removes all resize listeners.
|
|
1251
1850
|
*/
|
|
1252
1851
|
function setupColumnResize(headerEl, bodyEl, columns, onChange) {
|
|
1253
1852
|
const handles = headerEl.querySelectorAll(".gantt-col-resize-handle");
|
|
@@ -1300,19 +1899,22 @@ function setupColumnResize(headerEl, bodyEl, columns, onChange) {
|
|
|
1300
1899
|
};
|
|
1301
1900
|
}
|
|
1302
1901
|
//#endregion
|
|
1303
|
-
//#region src/
|
|
1902
|
+
//#region src/lib/vanilla/dom/dependencyLayer.ts
|
|
1304
1903
|
const NS = "http://www.w3.org/2000/svg";
|
|
1904
|
+
const ARROW_PATH = "M 0 1 L 10 5 L 0 9 Z";
|
|
1305
1905
|
const ARROW_SIZE = 6;
|
|
1306
1906
|
/**
|
|
1307
1907
|
* Creates the SVG overlay element. Call once; pass to updateDependencyLayer on each render.
|
|
1308
1908
|
* Also creates a hidden ghost-line path used during link-creation drags.
|
|
1909
|
+
*
|
|
1910
|
+
* The SVG is initially zero-sized; `updateDependencyLayer` sets width/height each frame.
|
|
1911
|
+
*
|
|
1912
|
+
* @param _totalWidth - The total pixel width of the SVG viewport.
|
|
1913
|
+
* @param _totalHeight - The total pixel height of the SVG viewport.
|
|
1914
|
+
* @returns An `SVGSVGElement` ready to be inserted into the DOM.
|
|
1309
1915
|
*/
|
|
1310
|
-
function createDependencyLayer(
|
|
1916
|
+
function createDependencyLayer(_totalWidth, _totalHeight) {
|
|
1311
1917
|
const svg = document.createElementNS(NS, "svg");
|
|
1312
|
-
setAttrs(svg, {
|
|
1313
|
-
width: totalWidth,
|
|
1314
|
-
height: totalHeight
|
|
1315
|
-
});
|
|
1316
1918
|
Object.assign(svg.style, {
|
|
1317
1919
|
position: "absolute",
|
|
1318
1920
|
top: "0",
|
|
@@ -1327,7 +1929,7 @@ function createDependencyLayer(totalWidth, totalHeight) {
|
|
|
1327
1929
|
setAttrs(marker, {
|
|
1328
1930
|
id,
|
|
1329
1931
|
viewBox: "0 0 10 10",
|
|
1330
|
-
refX: "
|
|
1932
|
+
refX: "10",
|
|
1331
1933
|
refY: "5",
|
|
1332
1934
|
markerWidth: ARROW_SIZE,
|
|
1333
1935
|
markerHeight: ARROW_SIZE,
|
|
@@ -1335,7 +1937,7 @@ function createDependencyLayer(totalWidth, totalHeight) {
|
|
|
1335
1937
|
});
|
|
1336
1938
|
const path = document.createElementNS(NS, "path");
|
|
1337
1939
|
setAttrs(path, {
|
|
1338
|
-
d:
|
|
1940
|
+
d: ARROW_PATH,
|
|
1339
1941
|
fill: color
|
|
1340
1942
|
});
|
|
1341
1943
|
marker.append(path);
|
|
@@ -1357,21 +1959,28 @@ function createDependencyLayer(totalWidth, totalHeight) {
|
|
|
1357
1959
|
}
|
|
1358
1960
|
/**
|
|
1359
1961
|
* Shows or updates the ghost line drawn during a link-creation drag.
|
|
1360
|
-
*
|
|
1962
|
+
*
|
|
1963
|
+
* @param svg - The SVG dependency layer element.
|
|
1964
|
+
* @param x1 - Start X coordinate.
|
|
1965
|
+
* @param y1 - Start Y coordinate.
|
|
1966
|
+
* @param x2 - End X coordinate.
|
|
1967
|
+
* @param y2 - End Y coordinate.
|
|
1968
|
+
* @param valid - When `true`, the line is drawn solid with an arrow marker.
|
|
1361
1969
|
*/
|
|
1362
1970
|
function showGhostLine(svg, x1, y1, x2, y2, valid) {
|
|
1363
1971
|
const ghost = svg.querySelector("path.gantt-ghost-line");
|
|
1364
1972
|
if (ghost === null) return;
|
|
1365
|
-
setAttrs(ghost, {
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
});
|
|
1973
|
+
setAttrs(ghost, { d: `M ${x1},${y1} L ${x2},${y2}` });
|
|
1974
|
+
if (valid) ghost.removeAttribute("stroke-dasharray");
|
|
1975
|
+
else ghost.setAttribute("stroke-dasharray", "5 3");
|
|
1369
1976
|
if (valid) ghost.setAttribute("marker-end", "url(#gantt-arrow)");
|
|
1370
1977
|
else ghost.removeAttribute("marker-end");
|
|
1371
1978
|
ghost.style.display = "";
|
|
1372
1979
|
}
|
|
1373
1980
|
/**
|
|
1374
1981
|
* Hides the ghost line after a link-creation drag completes or is cancelled.
|
|
1982
|
+
*
|
|
1983
|
+
* @param svg - The SVG dependency layer element.
|
|
1375
1984
|
*/
|
|
1376
1985
|
function hideGhostLine(svg) {
|
|
1377
1986
|
const ghost = svg.querySelector("path.gantt-ghost-line");
|
|
@@ -1383,26 +1992,28 @@ function hideGhostLine(svg) {
|
|
|
1383
1992
|
/**
|
|
1384
1993
|
* Replaces all path elements in the SVG to reflect the current link set.
|
|
1385
1994
|
* The `<defs>` node (first child) is preserved.
|
|
1995
|
+
*
|
|
1996
|
+
* @param svg - The SVG dependency layer element.
|
|
1997
|
+
* @param links - The array of routed links to render.
|
|
1998
|
+
* @param totalWidth - The total pixel width of the SVG viewport.
|
|
1999
|
+
* @param totalHeight - The total pixel height of the SVG viewport.
|
|
2000
|
+
* @param selectedTaskId - The currently selected task ID, or `null`.
|
|
2001
|
+
* @param highlightLinkedDependenciesOnSelect - When `true`, links connected to the selected task use highlight styling.
|
|
2002
|
+
* @param cbs - Optional callbacks for link click and double-click events.
|
|
1386
2003
|
*/
|
|
1387
|
-
function updateDependencyLayer(svg, links, totalWidth, totalHeight, selectedTaskId, highlightLinkedDependenciesOnSelect) {
|
|
2004
|
+
function updateDependencyLayer(svg, links, totalWidth, totalHeight, selectedTaskId, highlightLinkedDependenciesOnSelect, cbs) {
|
|
1388
2005
|
setAttrs(svg, {
|
|
1389
2006
|
width: totalWidth,
|
|
1390
2007
|
height: totalHeight
|
|
1391
2008
|
});
|
|
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
|
-
}
|
|
2009
|
+
const toRemove = [...svg.children].slice(1).filter((c) => !c.classList.contains("gantt-ghost-line"));
|
|
1397
2010
|
for (const node of toRemove) svg.removeChild(node);
|
|
2011
|
+
const ghost = svg.querySelector("path.gantt-ghost-line");
|
|
1398
2012
|
for (const link of links) {
|
|
1399
2013
|
const { points } = link;
|
|
1400
2014
|
if (points.length === 0) continue;
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
const p = points[i];
|
|
1404
|
-
if (p !== void 0) d += ` L ${p.x},${p.y}`;
|
|
1405
|
-
}
|
|
2015
|
+
const [first, ...rest] = points;
|
|
2016
|
+
const d = `M ${first.x},${first.y}${rest.map((p) => ` L ${p.x},${p.y}`).join("")}`;
|
|
1406
2017
|
const isRelated = highlightLinkedDependenciesOnSelect && selectedTaskId !== null && (link.sourceTaskId === selectedTaskId || link.targetTaskId === selectedTaskId);
|
|
1407
2018
|
const path = document.createElementNS(NS, "path");
|
|
1408
2019
|
setAttrs(path, {
|
|
@@ -1411,19 +2022,74 @@ function updateDependencyLayer(svg, links, totalWidth, totalHeight, selectedTask
|
|
|
1411
2022
|
stroke: isRelated ? "var(--gantt-link-hi)" : "var(--gantt-link)",
|
|
1412
2023
|
"stroke-width": isRelated ? "1.8" : "1.5",
|
|
1413
2024
|
"stroke-linejoin": "round",
|
|
1414
|
-
"marker-end": isRelated ? "url(#gantt-arrow-hi)" : "url(#gantt-arrow)"
|
|
2025
|
+
"marker-end": isRelated ? "url(#gantt-arrow-hi)" : "url(#gantt-arrow)",
|
|
2026
|
+
"data-link-id": String(link.linkId)
|
|
2027
|
+
});
|
|
2028
|
+
path.style.pointerEvents = "visibleStroke";
|
|
2029
|
+
path.style.cursor = "pointer";
|
|
2030
|
+
path.addEventListener("click", (event) => {
|
|
2031
|
+
if (event.detail === 1) cbs?.onLinkClick?.({
|
|
2032
|
+
id: link.linkId,
|
|
2033
|
+
source: link.sourceTaskId,
|
|
2034
|
+
target: link.targetTaskId,
|
|
2035
|
+
type: link.type
|
|
2036
|
+
});
|
|
2037
|
+
});
|
|
2038
|
+
path.addEventListener("dblclick", (_event) => {
|
|
2039
|
+
cbs?.onLinkDblClick?.({
|
|
2040
|
+
id: link.linkId,
|
|
2041
|
+
source: link.sourceTaskId,
|
|
2042
|
+
target: link.targetTaskId,
|
|
2043
|
+
type: link.type
|
|
2044
|
+
});
|
|
1415
2045
|
});
|
|
1416
|
-
svg.
|
|
2046
|
+
if (ghost !== null) svg.insertBefore(path, ghost);
|
|
2047
|
+
else svg.append(path);
|
|
1417
2048
|
}
|
|
1418
2049
|
}
|
|
1419
2050
|
//#endregion
|
|
1420
|
-
//#region src/
|
|
2051
|
+
//#region src/lib/vanilla/interaction/drag.ts
|
|
2052
|
+
function toTask(node) {
|
|
2053
|
+
const base = {
|
|
2054
|
+
id: node.id,
|
|
2055
|
+
text: node.text,
|
|
2056
|
+
startDate: node.startDate,
|
|
2057
|
+
...node.parent === void 0 ? {} : { parent: node.parent },
|
|
2058
|
+
...node.color === void 0 ? {} : { color: node.color },
|
|
2059
|
+
...node.data === void 0 ? {} : { data: node.data }
|
|
2060
|
+
};
|
|
2061
|
+
switch (node.kind) {
|
|
2062
|
+
case "task": return {
|
|
2063
|
+
...base,
|
|
2064
|
+
kind: "task",
|
|
2065
|
+
durationHours: node.durationHours,
|
|
2066
|
+
percentComplete: node.percentComplete
|
|
2067
|
+
};
|
|
2068
|
+
case "project": return {
|
|
2069
|
+
...base,
|
|
2070
|
+
kind: "project",
|
|
2071
|
+
durationHours: node.durationHours,
|
|
2072
|
+
percentComplete: node.percentComplete,
|
|
2073
|
+
open: node.open
|
|
2074
|
+
};
|
|
2075
|
+
case "milestone": return {
|
|
2076
|
+
...base,
|
|
2077
|
+
kind: "milestone"
|
|
2078
|
+
};
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
1421
2081
|
/**
|
|
1422
2082
|
* Attaches drag-to-move and resize listeners to a bar element.
|
|
1423
|
-
* Returns a cleanup function that removes all listeners.
|
|
1424
2083
|
*
|
|
1425
2084
|
* Design: all mutable state lives in closure variables captured at mousedown.
|
|
1426
2085
|
* No global state; multiple bars can be dragged independently (one at a time).
|
|
2086
|
+
*
|
|
2087
|
+
* @param barEl - The bar DOM element.
|
|
2088
|
+
* @param resizeHandleEl - The resize handle DOM element.
|
|
2089
|
+
* @param task - The {@link TaskNode} for this bar.
|
|
2090
|
+
* @param getMapper - A function returning the current {@link PixelMapper} (snapshotted at mousedown).
|
|
2091
|
+
* @param cbs - The chart callbacks.
|
|
2092
|
+
* @returns A cleanup function that removes all listeners.
|
|
1427
2093
|
*/
|
|
1428
2094
|
function attachDrag(barEl, resizeHandleEl, task, getMapper, cbs) {
|
|
1429
2095
|
function onBarDown(e) {
|
|
@@ -1432,22 +2098,27 @@ function attachDrag(barEl, resizeHandleEl, task, getMapper, cbs) {
|
|
|
1432
2098
|
try {
|
|
1433
2099
|
barEl.setPointerCapture(e.pointerId);
|
|
1434
2100
|
} catch {}
|
|
1435
|
-
cbs.
|
|
2101
|
+
cbs.onTaskSelect?.(task.id);
|
|
1436
2102
|
const startX = e.clientX;
|
|
1437
|
-
const originDate = parseDate(task.
|
|
2103
|
+
const originDate = parseDate(task.startDate);
|
|
1438
2104
|
const mapper = getMapper();
|
|
2105
|
+
let lastHours = 0;
|
|
1439
2106
|
function onMove(me) {
|
|
1440
2107
|
const dx = me.clientX - startX;
|
|
1441
|
-
|
|
1442
|
-
cbs.
|
|
2108
|
+
lastHours = Math.round(mapper.widthToDuration(dx));
|
|
2109
|
+
cbs.onTaskMove?.({
|
|
1443
2110
|
id: task.id,
|
|
1444
|
-
startDate:
|
|
2111
|
+
startDate: addHours(originDate, lastHours)
|
|
1445
2112
|
});
|
|
1446
2113
|
}
|
|
1447
2114
|
function onUp() {
|
|
1448
2115
|
window.removeEventListener("pointermove", onMove);
|
|
1449
2116
|
window.removeEventListener("pointerup", onUp);
|
|
1450
2117
|
barEl.style.cursor = "grab";
|
|
2118
|
+
cbs._onTaskMoveFinal?.({
|
|
2119
|
+
id: task.id,
|
|
2120
|
+
startDate: addHours(originDate, lastHours)
|
|
2121
|
+
});
|
|
1451
2122
|
}
|
|
1452
2123
|
barEl.style.cursor = "grabbing";
|
|
1453
2124
|
window.addEventListener("pointermove", onMove);
|
|
@@ -1461,29 +2132,33 @@ function attachDrag(barEl, resizeHandleEl, task, getMapper, cbs) {
|
|
|
1461
2132
|
resizeHandleEl.setPointerCapture(e.pointerId);
|
|
1462
2133
|
} catch {}
|
|
1463
2134
|
const startX = e.clientX;
|
|
1464
|
-
const origDur = task.
|
|
2135
|
+
const origDur = task.kind !== "milestone" ? task.durationHours : 0;
|
|
1465
2136
|
const mapper = getMapper();
|
|
2137
|
+
let lastDuration = origDur;
|
|
1466
2138
|
function onMove(me) {
|
|
1467
2139
|
const dx = me.clientX - startX;
|
|
1468
|
-
const
|
|
1469
|
-
|
|
2140
|
+
const hoursDelta = Math.round(mapper.widthToDuration(dx));
|
|
2141
|
+
lastDuration = Math.max(1, origDur + hoursDelta);
|
|
2142
|
+
cbs.onTaskResize?.({
|
|
1470
2143
|
id: task.id,
|
|
1471
|
-
|
|
2144
|
+
durationHours: lastDuration
|
|
1472
2145
|
});
|
|
1473
2146
|
}
|
|
1474
2147
|
function onUp() {
|
|
1475
2148
|
window.removeEventListener("pointermove", onMove);
|
|
1476
2149
|
window.removeEventListener("pointerup", onUp);
|
|
2150
|
+
cbs._onTaskResizeFinal?.({
|
|
2151
|
+
id: task.id,
|
|
2152
|
+
durationHours: lastDuration
|
|
2153
|
+
});
|
|
1477
2154
|
}
|
|
1478
2155
|
window.addEventListener("pointermove", onMove);
|
|
1479
2156
|
window.addEventListener("pointerup", onUp);
|
|
1480
2157
|
}
|
|
1481
2158
|
function onBarClick(event) {
|
|
1482
2159
|
if (event.detail !== 2) return;
|
|
1483
|
-
cbs.
|
|
2160
|
+
cbs.onTaskDoubleClick?.({
|
|
1484
2161
|
id: task.id,
|
|
1485
|
-
source: "bar",
|
|
1486
|
-
trigger: "double_click",
|
|
1487
2162
|
task: toTask(task)
|
|
1488
2163
|
});
|
|
1489
2164
|
}
|
|
@@ -1497,21 +2172,73 @@ function attachDrag(barEl, resizeHandleEl, task, getMapper, cbs) {
|
|
|
1497
2172
|
};
|
|
1498
2173
|
}
|
|
1499
2174
|
/**
|
|
2175
|
+
* Attaches drag-to-change-progress listeners to a progress overlay element.
|
|
2176
|
+
*
|
|
2177
|
+
* @param progressEl - The progress overlay DOM element.
|
|
2178
|
+
* @param barEl - The bar DOM element (for width measurement).
|
|
2179
|
+
* @param task - The {@link TaskNode} for this bar.
|
|
2180
|
+
* @param _getMapper - A function returning the current {@link PixelMapper} (unused, kept for API symmetry).
|
|
2181
|
+
* @param cbs - The chart callbacks.
|
|
2182
|
+
* @returns A cleanup function that removes all listeners.
|
|
2183
|
+
*/
|
|
2184
|
+
function attachProgressDrag(progressEl, barEl, task, _getMapper, cbs) {
|
|
2185
|
+
function onProgressDown(e) {
|
|
2186
|
+
if (e.button !== 0) return;
|
|
2187
|
+
e.preventDefault();
|
|
2188
|
+
e.stopPropagation();
|
|
2189
|
+
cbs.onTaskSelect?.(task.id);
|
|
2190
|
+
try {
|
|
2191
|
+
progressEl.setPointerCapture(e.pointerId);
|
|
2192
|
+
} catch {}
|
|
2193
|
+
const startX = e.clientX;
|
|
2194
|
+
const barWidth = barEl.getBoundingClientRect().width;
|
|
2195
|
+
const origPercent = task.kind !== "milestone" ? task.percentComplete ?? 0 : 0;
|
|
2196
|
+
let lastPercent = origPercent;
|
|
2197
|
+
function onMove(me) {
|
|
2198
|
+
const dx = me.clientX - startX;
|
|
2199
|
+
const percentDelta = barWidth > 0 ? dx / barWidth * 100 : 0;
|
|
2200
|
+
lastPercent = Math.max(0, Math.min(100, Math.round(origPercent + percentDelta)));
|
|
2201
|
+
cbs.onTaskProgressDrag?.({
|
|
2202
|
+
id: task.id,
|
|
2203
|
+
percentComplete: lastPercent
|
|
2204
|
+
});
|
|
2205
|
+
}
|
|
2206
|
+
function onUp() {
|
|
2207
|
+
window.removeEventListener("pointermove", onMove);
|
|
2208
|
+
window.removeEventListener("pointerup", onUp);
|
|
2209
|
+
progressEl.style.cursor = "ew-resize";
|
|
2210
|
+
cbs._onTaskProgressDragFinal?.({
|
|
2211
|
+
id: task.id,
|
|
2212
|
+
percentComplete: lastPercent
|
|
2213
|
+
});
|
|
2214
|
+
}
|
|
2215
|
+
progressEl.style.cursor = "ew-resize";
|
|
2216
|
+
window.addEventListener("pointermove", onMove);
|
|
2217
|
+
window.addEventListener("pointerup", onUp);
|
|
2218
|
+
}
|
|
2219
|
+
progressEl.addEventListener("pointerdown", onProgressDown);
|
|
2220
|
+
return () => {
|
|
2221
|
+
progressEl.removeEventListener("pointerdown", onProgressDown);
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
/**
|
|
1500
2225
|
* Attaches click-to-select on a milestone diamond.
|
|
1501
|
-
*
|
|
2226
|
+
*
|
|
2227
|
+
* @param diamondEl - The milestone diamond DOM element.
|
|
2228
|
+
* @param taskId - The task ID to select.
|
|
2229
|
+
* @param cbs - The chart callbacks.
|
|
2230
|
+
* @returns A cleanup function that removes all listeners.
|
|
1502
2231
|
*/
|
|
1503
2232
|
function attachMilestoneClick(diamondEl, taskId, cbs) {
|
|
1504
2233
|
function onClick() {
|
|
1505
|
-
cbs.
|
|
2234
|
+
cbs.onTaskSelect?.(taskId);
|
|
1506
2235
|
}
|
|
1507
2236
|
function onDoubleClick(event) {
|
|
1508
2237
|
if (event.detail === 2) {
|
|
1509
2238
|
const task = diamondEl.__task;
|
|
1510
2239
|
if (task === void 0) return;
|
|
1511
|
-
cbs.
|
|
2240
|
+
cbs.onTaskDoubleClick?.({
|
|
1512
2241
|
id: taskId,
|
|
1513
|
-
source: "milestone",
|
|
1514
|
-
trigger: "double_click",
|
|
1515
2242
|
task
|
|
1516
2243
|
});
|
|
1517
2244
|
}
|
|
@@ -1526,24 +2253,19 @@ function attachMilestoneClick(diamondEl, taskId, cbs) {
|
|
|
1526
2253
|
function bindMilestoneTask(diamondEl, task) {
|
|
1527
2254
|
diamondEl.__task = task;
|
|
1528
2255
|
}
|
|
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
2256
|
//#endregion
|
|
1543
|
-
//#region src/
|
|
2257
|
+
//#region src/lib/vanilla/interaction/linkCreation.ts
|
|
1544
2258
|
/**
|
|
1545
2259
|
* Attaches a link-creation drag listener to an endpoint handle.
|
|
1546
|
-
*
|
|
2260
|
+
*
|
|
2261
|
+
* @param handle - The endpoint DOM element.
|
|
2262
|
+
* @param sourceTaskId - The task ID from which the link originates.
|
|
2263
|
+
* @param anchorX - The X anchor coordinate (task center).
|
|
2264
|
+
* @param anchorY - The Y anchor coordinate (task center).
|
|
2265
|
+
* @param svgLayer - The SVG dependency layer element for ghost line rendering.
|
|
2266
|
+
* @param absoluteLayer - The absolute-positioned layer for coordinate calculations.
|
|
2267
|
+
* @param cbs - The chart callbacks.
|
|
2268
|
+
* @returns A cleanup function that removes all listeners.
|
|
1547
2269
|
*/
|
|
1548
2270
|
function attachLinkEndpointHandle(handle, sourceTaskId, anchorX, anchorY, svgLayer, absoluteLayer, cbs) {
|
|
1549
2271
|
function onPointerDown(e) {
|
|
@@ -1592,6 +2314,8 @@ function attachLinkEndpointHandle(handle, sourceTaskId, anchorX, anchorY, svgLay
|
|
|
1592
2314
|
/**
|
|
1593
2315
|
* Creates an endpoint handle DOM element.
|
|
1594
2316
|
* The caller must position it with inline styles and append it to the layer.
|
|
2317
|
+
*
|
|
2318
|
+
* @returns A new `HTMLElement` with the `gantt-link-endpoint` class.
|
|
1595
2319
|
*/
|
|
1596
2320
|
function createEndpointHandle() {
|
|
1597
2321
|
const handle = document.createElement("div");
|
|
@@ -1612,13 +2336,17 @@ function createEndpointHandle() {
|
|
|
1612
2336
|
return handle;
|
|
1613
2337
|
}
|
|
1614
2338
|
//#endregion
|
|
1615
|
-
//#region src/
|
|
2339
|
+
//#region src/lib/vanilla/dom/rightPane.ts
|
|
1616
2340
|
const BAR_COLOR = {
|
|
1617
2341
|
task: "var(--gantt-task)",
|
|
1618
2342
|
project: "var(--gantt-project)",
|
|
1619
2343
|
milestone: "var(--gantt-milestone)"
|
|
1620
2344
|
};
|
|
1621
|
-
/**
|
|
2345
|
+
/**
|
|
2346
|
+
* Creates the skeleton DOM structure for the right pane. Call once.
|
|
2347
|
+
*
|
|
2348
|
+
* @returns A new {@link RightPaneRefs} with empty containers and bar registry.
|
|
2349
|
+
*/
|
|
1622
2350
|
function createRightPaneRefs() {
|
|
1623
2351
|
const scrollContainer = el("div");
|
|
1624
2352
|
const stripeContainer = el("div");
|
|
@@ -1627,7 +2355,7 @@ function createRightPaneRefs() {
|
|
|
1627
2355
|
css(stripeContainer, { position: "relative" });
|
|
1628
2356
|
css(absoluteLayer, {
|
|
1629
2357
|
position: "absolute",
|
|
1630
|
-
top: "
|
|
2358
|
+
top: "52px",
|
|
1631
2359
|
left: "0"
|
|
1632
2360
|
});
|
|
1633
2361
|
scrollContainer.append(stripeContainer);
|
|
@@ -1641,94 +2369,8 @@ function createRightPaneRefs() {
|
|
|
1641
2369
|
barRegistry: /* @__PURE__ */ new Map()
|
|
1642
2370
|
};
|
|
1643
2371
|
}
|
|
1644
|
-
|
|
1645
|
-
|
|
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);
|
|
2372
|
+
function ariaLabel(locale, key, arg) {
|
|
2373
|
+
return formatLabel(locale.labels?.[key] ?? EN_US_LABELS[key], arg);
|
|
1732
2374
|
}
|
|
1733
2375
|
function renderSpecialDayBackgrounds(layer, beforeNode, state, contentHeight) {
|
|
1734
2376
|
const { mapper, viewportStart, viewportEnd, showWeekends, weekendDays, specialDaysByDate } = state;
|
|
@@ -1770,7 +2412,7 @@ function renderSpecialDayBackgrounds(layer, beforeNode, state, contentHeight) {
|
|
|
1770
2412
|
}
|
|
1771
2413
|
function renderBar(layer, svgLayer, task, layout, selectedId, registry, state, cbs) {
|
|
1772
2414
|
const selected = task.id === selectedId;
|
|
1773
|
-
const color = BAR_COLOR[layout.
|
|
2415
|
+
const color = BAR_COLOR[layout.kind] ?? BAR_COLOR["task"];
|
|
1774
2416
|
const bar = el("div");
|
|
1775
2417
|
bar.className = `gantt-bar${selected ? " gantt-bar--selected gantt-shape--selected" : ""}`;
|
|
1776
2418
|
css(bar, {
|
|
@@ -1780,15 +2422,17 @@ function renderBar(layer, svgLayer, task, layout, selectedId, registry, state, c
|
|
|
1780
2422
|
width: `${layout.width}px`,
|
|
1781
2423
|
height: `${layout.height}px`,
|
|
1782
2424
|
...color === void 0 ? {} : { background: color },
|
|
1783
|
-
borderRadius: layout.
|
|
2425
|
+
borderRadius: layout.kind === "project" ? "3px" : "4px",
|
|
1784
2426
|
cursor: "grab",
|
|
1785
2427
|
userSelect: "none",
|
|
1786
2428
|
overflow: "hidden",
|
|
1787
2429
|
zIndex: selected ? "3" : "2",
|
|
1788
2430
|
touchAction: "none"
|
|
1789
2431
|
});
|
|
2432
|
+
let cleanupProgressDrag;
|
|
1790
2433
|
if (layout.progressWidth > 0) {
|
|
1791
2434
|
const prog = el("div");
|
|
2435
|
+
const progressEnabled = state.progressDragEnabled;
|
|
1792
2436
|
css(prog, {
|
|
1793
2437
|
position: "absolute",
|
|
1794
2438
|
left: "0",
|
|
@@ -1796,8 +2440,15 @@ function renderBar(layer, svgLayer, task, layout, selectedId, registry, state, c
|
|
|
1796
2440
|
width: `${layout.progressWidth}px`,
|
|
1797
2441
|
height: "100%",
|
|
1798
2442
|
background: "rgba(0,0,0,0.18)",
|
|
1799
|
-
|
|
2443
|
+
...progressEnabled ? {
|
|
2444
|
+
cursor: "ew-resize",
|
|
2445
|
+
touchAction: "none"
|
|
2446
|
+
} : { pointerEvents: "none" }
|
|
1800
2447
|
});
|
|
2448
|
+
if (progressEnabled) {
|
|
2449
|
+
prog.className = "gantt-progress-overlay";
|
|
2450
|
+
cleanupProgressDrag = attachProgressDrag(prog, bar, task, () => state.mapper, cbs);
|
|
2451
|
+
}
|
|
1801
2452
|
bar.append(prog);
|
|
1802
2453
|
}
|
|
1803
2454
|
const label = el("span");
|
|
@@ -1820,16 +2471,16 @@ function renderBar(layer, svgLayer, task, layout, selectedId, registry, state, c
|
|
|
1820
2471
|
bar.append(label);
|
|
1821
2472
|
bar.tabIndex = 0;
|
|
1822
2473
|
bar.setAttribute("role", "button");
|
|
1823
|
-
bar.setAttribute("aria-label", ariaLabel(state.locale, "
|
|
2474
|
+
bar.setAttribute("aria-label", ariaLabel(state.locale, "ariaTask", task.text));
|
|
1824
2475
|
bar.setAttribute("aria-pressed", String(selected));
|
|
1825
2476
|
bar.dataset["taskId"] = String(task.id);
|
|
1826
2477
|
bar.addEventListener("click", () => {
|
|
1827
|
-
cbs.
|
|
2478
|
+
cbs.onTaskSelect?.(task.id);
|
|
1828
2479
|
});
|
|
1829
2480
|
bar.addEventListener("keydown", (event) => {
|
|
1830
2481
|
if (event.key === "Enter" || event.key === " ") {
|
|
1831
2482
|
event.preventDefault();
|
|
1832
|
-
cbs.
|
|
2483
|
+
cbs.onTaskSelect?.(task.id);
|
|
1833
2484
|
}
|
|
1834
2485
|
});
|
|
1835
2486
|
const handle = el("div");
|
|
@@ -1887,6 +2538,7 @@ function renderBar(layer, svgLayer, task, layout, selectedId, registry, state, c
|
|
|
1887
2538
|
cleanupDrag
|
|
1888
2539
|
};
|
|
1889
2540
|
if (cleanupLinkHandles !== void 0) entry.cleanupLinkHandles = cleanupLinkHandles;
|
|
2541
|
+
if (cleanupProgressDrag !== void 0) entry.cleanupProgressDrag = cleanupProgressDrag;
|
|
1890
2542
|
registry.set(task.id, entry);
|
|
1891
2543
|
}
|
|
1892
2544
|
function renderMilestone(layer, svgLayer, task, layout, selectedId, registry, cbs, state) {
|
|
@@ -1907,13 +2559,13 @@ function renderMilestone(layer, svgLayer, task, layout, selectedId, registry, cb
|
|
|
1907
2559
|
});
|
|
1908
2560
|
diamond.tabIndex = 0;
|
|
1909
2561
|
diamond.setAttribute("role", "button");
|
|
1910
|
-
diamond.setAttribute("aria-label", ariaLabel(state.locale, "
|
|
2562
|
+
diamond.setAttribute("aria-label", ariaLabel(state.locale, "ariaMilestone", task.text));
|
|
1911
2563
|
diamond.setAttribute("aria-pressed", String(selected));
|
|
1912
2564
|
diamond.dataset["taskId"] = String(task.id);
|
|
1913
2565
|
diamond.addEventListener("keydown", (event) => {
|
|
1914
2566
|
if (event.key === "Enter" || event.key === " ") {
|
|
1915
2567
|
event.preventDefault();
|
|
1916
|
-
cbs.
|
|
2568
|
+
cbs.onTaskSelect?.(task.id);
|
|
1917
2569
|
}
|
|
1918
2570
|
});
|
|
1919
2571
|
const labelEl = el("span");
|
|
@@ -1967,11 +2619,102 @@ function renderMilestone(layer, svgLayer, task, layout, selectedId, registry, cb
|
|
|
1967
2619
|
if (cleanupLinkHandles !== void 0) entry.cleanupLinkHandles = cleanupLinkHandles;
|
|
1968
2620
|
registry.set(task.id, entry);
|
|
1969
2621
|
}
|
|
1970
|
-
|
|
1971
|
-
|
|
2622
|
+
/**
|
|
2623
|
+
* Full render of the right pane.
|
|
2624
|
+
* Grid lines and stripes are rebuilt each call (cheap — no event listeners).
|
|
2625
|
+
* Bars are rebuilt each call with fresh drag listeners (old ones cleaned up first).
|
|
2626
|
+
*
|
|
2627
|
+
* @param refs - The right pane DOM references.
|
|
2628
|
+
* @param state - The current chart state.
|
|
2629
|
+
* @param cbs - The chart callbacks.
|
|
2630
|
+
*/
|
|
2631
|
+
function renderRightPane(refs, state, cbs) {
|
|
2632
|
+
const { allRows, layouts, links, mapper, scale, viewportStart, viewportEnd, totalWidth, selectedId, highlightLinkedDependenciesOnSelect, paddingTop, paddingBottom, startIndex } = state;
|
|
2633
|
+
const { stripeContainer, absoluteLayer, svgLayer, barRegistry } = refs;
|
|
2634
|
+
const rowCount = allRows.length;
|
|
2635
|
+
const contentHeight = totalContentHeight(rowCount);
|
|
2636
|
+
const visibleRows = allRows.slice(state.startIndex, state.endIndex + 1);
|
|
2637
|
+
clearChildren(stripeContainer);
|
|
2638
|
+
css(stripeContainer, { width: `${totalWidth}px` });
|
|
2639
|
+
if (paddingTop > 0) {
|
|
2640
|
+
const s = el("div");
|
|
2641
|
+
s.style.height = `${paddingTop}px`;
|
|
2642
|
+
stripeContainer.append(s);
|
|
2643
|
+
}
|
|
2644
|
+
for (let i = 0; i < visibleRows.length; i++) {
|
|
2645
|
+
const rowIdx = startIndex + i;
|
|
2646
|
+
const stripe = el("div");
|
|
2647
|
+
css(stripe, {
|
|
2648
|
+
height: `${ROW_HEIGHT}px`,
|
|
2649
|
+
background: rowIdx % 2 === 0 ? "var(--gantt-bg)" : "var(--gantt-stripe)",
|
|
2650
|
+
borderBottom: "1px solid var(--gantt-border)"
|
|
2651
|
+
});
|
|
2652
|
+
stripeContainer.append(stripe);
|
|
2653
|
+
}
|
|
2654
|
+
if (paddingBottom > 0) {
|
|
2655
|
+
const s = el("div");
|
|
2656
|
+
s.style.height = `${paddingBottom}px`;
|
|
2657
|
+
stripeContainer.append(s);
|
|
2658
|
+
}
|
|
2659
|
+
css(absoluteLayer, {
|
|
2660
|
+
width: `${totalWidth}px`,
|
|
2661
|
+
height: `${contentHeight}px`
|
|
2662
|
+
});
|
|
2663
|
+
const toRemove = [];
|
|
2664
|
+
for (const child of [...absoluteLayer.children]) if (child !== svgLayer) toRemove.push(child);
|
|
2665
|
+
for (const node of toRemove) absoluteLayer.removeChild(node);
|
|
2666
|
+
hideGhostLine(svgLayer);
|
|
2667
|
+
for (const { cleanupDrag, cleanupLinkHandles, cleanupProgressDrag } of barRegistry.values()) {
|
|
2668
|
+
cleanupDrag();
|
|
2669
|
+
cleanupLinkHandles?.();
|
|
2670
|
+
cleanupProgressDrag?.();
|
|
2671
|
+
}
|
|
2672
|
+
barRegistry.clear();
|
|
2673
|
+
if (scale === "day") renderSpecialDayBackgrounds(absoluteLayer, svgLayer, state, contentHeight);
|
|
2674
|
+
let gridCur = snapToScaleBoundary(viewportStart, scale);
|
|
2675
|
+
while (gridCur <= viewportEnd) {
|
|
2676
|
+
const x = mapper.toX(gridCur);
|
|
2677
|
+
const line = el("div");
|
|
2678
|
+
css(line, {
|
|
2679
|
+
position: "absolute",
|
|
2680
|
+
left: `${x}px`,
|
|
2681
|
+
top: "0",
|
|
2682
|
+
width: "1px",
|
|
2683
|
+
height: `${contentHeight}px`,
|
|
2684
|
+
background: "var(--gantt-grid-line)",
|
|
2685
|
+
pointerEvents: "none"
|
|
2686
|
+
});
|
|
2687
|
+
absoluteLayer.insertBefore(line, svgLayer);
|
|
2688
|
+
gridCur = nextScaleBoundary(gridCur, scale);
|
|
2689
|
+
}
|
|
2690
|
+
const todayX = mapper.toX(/* @__PURE__ */ new Date());
|
|
2691
|
+
const todayLineWidth = 2;
|
|
2692
|
+
if (todayX >= 0 && todayX <= totalWidth - todayLineWidth) {
|
|
2693
|
+
const todayLine = el("div");
|
|
2694
|
+
todayLine.className = "gantt-today-marker";
|
|
2695
|
+
css(todayLine, {
|
|
2696
|
+
position: "absolute",
|
|
2697
|
+
left: `${todayX}px`,
|
|
2698
|
+
top: "0",
|
|
2699
|
+
width: `${todayLineWidth}px`,
|
|
2700
|
+
height: `${contentHeight}px`,
|
|
2701
|
+
background: "var(--gantt-today)",
|
|
2702
|
+
pointerEvents: "none",
|
|
2703
|
+
zIndex: "5"
|
|
2704
|
+
});
|
|
2705
|
+
absoluteLayer.insertBefore(todayLine, svgLayer);
|
|
2706
|
+
}
|
|
2707
|
+
const visibleTaskIds = new Set(visibleRows.map((task) => task.id));
|
|
2708
|
+
for (const task of visibleRows) {
|
|
2709
|
+
const layout = layouts.get(task.id);
|
|
2710
|
+
if (layout === void 0) continue;
|
|
2711
|
+
if (layout.kind === "milestone") renderMilestone(absoluteLayer, svgLayer, task, layout, selectedId, barRegistry, cbs, state);
|
|
2712
|
+
else renderBar(absoluteLayer, svgLayer, task, layout, selectedId, barRegistry, state, cbs);
|
|
2713
|
+
}
|
|
2714
|
+
updateDependencyLayer(svgLayer, links.filter((link) => visibleTaskIds.has(link.sourceTaskId) && visibleTaskIds.has(link.targetTaskId)), totalWidth, contentHeight, selectedId, highlightLinkedDependenciesOnSelect, cbs);
|
|
1972
2715
|
}
|
|
1973
2716
|
//#endregion
|
|
1974
|
-
//#region src/
|
|
2717
|
+
//#region src/lib/vanilla/utils.ts
|
|
1975
2718
|
function buildTaskIndex(tasks) {
|
|
1976
2719
|
const index = /* @__PURE__ */ new Map();
|
|
1977
2720
|
for (let i = 0; i < tasks.length; i++) {
|
|
@@ -1980,6 +2723,9 @@ function buildTaskIndex(tasks) {
|
|
|
1980
2723
|
}
|
|
1981
2724
|
return index;
|
|
1982
2725
|
}
|
|
2726
|
+
function toIsoDate(date) {
|
|
2727
|
+
return date.toISOString().slice(0, 10);
|
|
2728
|
+
}
|
|
1983
2729
|
function buildSpecialDayIndex(specialDays) {
|
|
1984
2730
|
const map = /* @__PURE__ */ new Map();
|
|
1985
2731
|
for (const specialDay of specialDays) {
|
|
@@ -1993,9 +2739,6 @@ function buildSpecialDayIndex(specialDays) {
|
|
|
1993
2739
|
}
|
|
1994
2740
|
return map;
|
|
1995
2741
|
}
|
|
1996
|
-
function toIsoDate(date) {
|
|
1997
|
-
return date.toISOString().slice(0, 10);
|
|
1998
|
-
}
|
|
1999
2742
|
function normalizeWeekendDays(days) {
|
|
2000
2743
|
if (days === void 0) return new Set([0, 6]);
|
|
2001
2744
|
const normalized = /* @__PURE__ */ new Set();
|
|
@@ -2020,11 +2763,11 @@ function getExpandableTaskIds(tasks) {
|
|
|
2020
2763
|
function getInitialExpandedIds(tasks) {
|
|
2021
2764
|
const expandableIds = getExpandableTaskIds(tasks);
|
|
2022
2765
|
const expandedIds = /* @__PURE__ */ new Set();
|
|
2023
|
-
for (const task of tasks) if (task.open && expandableIds.has(task.id)) expandedIds.add(task.id);
|
|
2766
|
+
for (const task of tasks) if (task.kind === "project" && task.open && expandableIds.has(task.id)) expandedIds.add(task.id);
|
|
2024
2767
|
return expandedIds;
|
|
2025
2768
|
}
|
|
2026
2769
|
//#endregion
|
|
2027
|
-
//#region src/
|
|
2770
|
+
//#region src/lib/vanilla/splitter.ts
|
|
2028
2771
|
const MIN_PANE_WIDTH$1 = 96;
|
|
2029
2772
|
function attachSplitter(splitterHandle, leftPane, container, timelineMinWidth, onDragEnd) {
|
|
2030
2773
|
splitterHandle.addEventListener("pointerdown", (e) => {
|
|
@@ -2073,19 +2816,34 @@ function computeLeftPaneWidth(options) {
|
|
|
2073
2816
|
return Math.max(MIN_PANE_WIDTH, Math.floor(width));
|
|
2074
2817
|
}
|
|
2075
2818
|
//#endregion
|
|
2076
|
-
//#region src/
|
|
2819
|
+
//#region src/lib/vanilla/gantt-chart.ts
|
|
2077
2820
|
const HEADER_H = 52;
|
|
2078
2821
|
const OVERSCAN = 4;
|
|
2822
|
+
/**
|
|
2823
|
+
* Progressive-enhancement Gantt chart component.
|
|
2824
|
+
* Validates input, builds a DOM tree, and renders a full interactive chart
|
|
2825
|
+
* inside the given container element.
|
|
2826
|
+
*
|
|
2827
|
+
* @example
|
|
2828
|
+
* ```ts
|
|
2829
|
+
* const chart = new GanttChart(document.getElementById('chart')!, input, {
|
|
2830
|
+
* locale: 'de-DE',
|
|
2831
|
+
* theme: 'dark',
|
|
2832
|
+
* });
|
|
2833
|
+
* ```
|
|
2834
|
+
*/
|
|
2079
2835
|
var GanttChart = class {
|
|
2080
2836
|
#container;
|
|
2081
2837
|
#opts;
|
|
2082
|
-
#
|
|
2838
|
+
#callbacks;
|
|
2839
|
+
#input = null;
|
|
2083
2840
|
#scale;
|
|
2084
2841
|
#selectedId = null;
|
|
2085
2842
|
#scrollTop = 0;
|
|
2086
2843
|
#rafPending = false;
|
|
2087
2844
|
#rafId = null;
|
|
2088
2845
|
#destroyed = false;
|
|
2846
|
+
#dragOriginals = /* @__PURE__ */ new Map();
|
|
2089
2847
|
#taskIndex;
|
|
2090
2848
|
#lastGridClick = null;
|
|
2091
2849
|
#userSplitWidth = null;
|
|
@@ -2100,6 +2858,7 @@ var GanttChart = class {
|
|
|
2100
2858
|
#root;
|
|
2101
2859
|
#scrollEl;
|
|
2102
2860
|
#leftPane;
|
|
2861
|
+
#leftHeader;
|
|
2103
2862
|
#leftBody;
|
|
2104
2863
|
#rightPane;
|
|
2105
2864
|
#rightHeader;
|
|
@@ -2107,14 +2866,19 @@ var GanttChart = class {
|
|
|
2107
2866
|
#cbs;
|
|
2108
2867
|
#resizeObserver = null;
|
|
2109
2868
|
#columnResizeCleanup;
|
|
2110
|
-
|
|
2869
|
+
/**
|
|
2870
|
+
* Constructs a new chart, builds the DOM, and wires internal event handling.
|
|
2871
|
+
* Data must be loaded via {@link update} before the chart renders.
|
|
2872
|
+
*
|
|
2873
|
+
* @param container - The host `HTMLElement` the chart will be appended to.
|
|
2874
|
+
* @param opts - Configuration and callback options.
|
|
2875
|
+
*/
|
|
2876
|
+
constructor(container, opts = {}, cbs = {}) {
|
|
2111
2877
|
this.#container = container;
|
|
2112
|
-
validateLinkRefs(input.tasks, input.links);
|
|
2113
|
-
detectCycles(input.tasks, input.links);
|
|
2114
|
-
this.#input = input;
|
|
2115
2878
|
this.#scale = opts.scale ?? "day";
|
|
2116
2879
|
this.#opts = opts;
|
|
2117
|
-
this.#
|
|
2880
|
+
this.#callbacks = cbs;
|
|
2881
|
+
this.#taskIndex = /* @__PURE__ */ new Map();
|
|
2118
2882
|
this.#locale = resolveChartLocale(opts.locale);
|
|
2119
2883
|
this.#columns = opts.gridColumns ?? gridColumnDefaults(this.#locale);
|
|
2120
2884
|
this.#leftPaneDefaultWidth = opts.leftPaneWidth ?? gridNaturalWidth(this.#columns);
|
|
@@ -2122,43 +2886,117 @@ var GanttChart = class {
|
|
|
2122
2886
|
this.#timelineMinWidth = opts.timelineMinWidth ?? 220;
|
|
2123
2887
|
this.#weekendDays = normalizeWeekendDays(opts.weekendDays ?? this.#locale.weekendDays);
|
|
2124
2888
|
this.#specialDaysByDate = buildSpecialDayIndex(opts.specialDays ?? []);
|
|
2125
|
-
this.#expandedIds =
|
|
2889
|
+
this.#expandedIds = /* @__PURE__ */ new Set();
|
|
2126
2890
|
this.#cbs = {
|
|
2127
|
-
|
|
2891
|
+
onTaskSelect: (id) => {
|
|
2128
2892
|
if (this.#selectedId === id) return;
|
|
2129
2893
|
this.#selectedId = id;
|
|
2130
|
-
|
|
2894
|
+
if (this.#selectedId !== null) {
|
|
2895
|
+
const task = this.#findTask(this.#selectedId);
|
|
2896
|
+
if (task !== void 0) this.#callbacks.onTaskSelect?.({ task });
|
|
2897
|
+
}
|
|
2131
2898
|
this.#scheduleRender();
|
|
2132
2899
|
},
|
|
2133
2900
|
onTaskDoubleClick: (payload) => {
|
|
2134
|
-
|
|
2901
|
+
this.#callbacks.onTaskDoubleClick?.({ task: payload.task });
|
|
2135
2902
|
},
|
|
2136
2903
|
onTaskEditIntent: (payload) => {
|
|
2137
|
-
|
|
2138
|
-
opts.onTaskDoubleClick?.({
|
|
2139
|
-
id: payload.id,
|
|
2140
|
-
source: payload.source
|
|
2141
|
-
});
|
|
2904
|
+
this.#callbacks.onTaskDoubleClick?.({ task: payload.task });
|
|
2142
2905
|
},
|
|
2143
|
-
|
|
2906
|
+
onTaskMove: (payload) => {
|
|
2907
|
+
if (!this.#dragOriginals.has(payload.id)) {
|
|
2908
|
+
const task = this.#input?.tasks.find((t) => t.id === payload.id);
|
|
2909
|
+
if (task !== void 0) this.#dragOriginals.set(payload.id, task);
|
|
2910
|
+
}
|
|
2144
2911
|
const iso = payload.startDate.toISOString().slice(0, 10);
|
|
2145
|
-
this.#patchTask(payload.id, {
|
|
2146
|
-
|
|
2912
|
+
this.#patchTask(payload.id, { startDate: iso });
|
|
2913
|
+
this.#scheduleRender();
|
|
2914
|
+
},
|
|
2915
|
+
_onTaskMoveFinal: (payload) => {
|
|
2916
|
+
const task = this.#findTask(payload.id);
|
|
2917
|
+
if (task !== void 0) {
|
|
2918
|
+
if (this.#callbacks.onTaskMove?.({
|
|
2919
|
+
task,
|
|
2920
|
+
newStartDate: payload.startDate
|
|
2921
|
+
}) === false) {
|
|
2922
|
+
const original = this.#dragOriginals.get(payload.id);
|
|
2923
|
+
if (original !== void 0) this.#patchTask(payload.id, { startDate: original.startDate });
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
this.#dragOriginals.clear();
|
|
2927
|
+
this.#scheduleRender();
|
|
2928
|
+
return true;
|
|
2929
|
+
},
|
|
2930
|
+
onTaskResize: (payload) => {
|
|
2931
|
+
if (!this.#dragOriginals.has(payload.id)) {
|
|
2932
|
+
const task = this.#input?.tasks.find((t) => t.id === payload.id);
|
|
2933
|
+
if (task !== void 0) this.#dragOriginals.set(payload.id, task);
|
|
2934
|
+
}
|
|
2935
|
+
this.#patchTask(payload.id, { durationHours: payload.durationHours });
|
|
2147
2936
|
this.#scheduleRender();
|
|
2148
2937
|
},
|
|
2149
|
-
|
|
2150
|
-
this.#
|
|
2151
|
-
|
|
2938
|
+
_onTaskResizeFinal: (payload) => {
|
|
2939
|
+
const task = this.#findTask(payload.id);
|
|
2940
|
+
if (task !== void 0) {
|
|
2941
|
+
if (this.#callbacks.onTaskResize?.({
|
|
2942
|
+
task,
|
|
2943
|
+
newDurationHours: payload.durationHours
|
|
2944
|
+
}) === false) {
|
|
2945
|
+
const original = this.#dragOriginals.get(payload.id);
|
|
2946
|
+
if (original !== void 0 && original.kind !== "milestone") this.#patchTask(payload.id, { durationHours: original.durationHours });
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
this.#dragOriginals.clear();
|
|
2950
|
+
this.#scheduleRender();
|
|
2951
|
+
return true;
|
|
2952
|
+
},
|
|
2953
|
+
onTaskProgressDrag: (payload) => {
|
|
2954
|
+
if (!this.#dragOriginals.has(payload.id)) {
|
|
2955
|
+
const task = this.#input?.tasks.find((t) => t.id === payload.id);
|
|
2956
|
+
if (task !== void 0) this.#dragOriginals.set(payload.id, task);
|
|
2957
|
+
}
|
|
2958
|
+
this.#patchTask(payload.id, { percentComplete: payload.percentComplete });
|
|
2959
|
+
this.#scheduleRender();
|
|
2960
|
+
},
|
|
2961
|
+
_onTaskProgressDragFinal: (payload) => {
|
|
2962
|
+
const task = this.#findTask(payload.id);
|
|
2963
|
+
if (task !== void 0) {
|
|
2964
|
+
if (this.#callbacks.onProgressChange?.({
|
|
2965
|
+
task,
|
|
2966
|
+
newPercentComplete: payload.percentComplete
|
|
2967
|
+
}) === false) {
|
|
2968
|
+
const original = this.#dragOriginals.get(payload.id);
|
|
2969
|
+
if (original !== void 0 && original.kind !== "milestone") this.#patchTask(payload.id, { percentComplete: original.percentComplete });
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
this.#dragOriginals.clear();
|
|
2152
2973
|
this.#scheduleRender();
|
|
2974
|
+
return true;
|
|
2975
|
+
},
|
|
2976
|
+
onTaskAdd: (parentId) => {
|
|
2977
|
+
const parentTask = this.#findTask(parentId);
|
|
2978
|
+
if (parentTask !== void 0) this.#callbacks.onTaskAdd?.({ parentTask });
|
|
2153
2979
|
},
|
|
2154
2980
|
onLeftPaneWidthChange: (width) => {
|
|
2155
|
-
|
|
2981
|
+
this.#callbacks.onLeftPaneWidthChange?.(width);
|
|
2156
2982
|
},
|
|
2157
2983
|
onGridColumnsChange: (updatedColumns) => {
|
|
2158
|
-
|
|
2984
|
+
this.#callbacks.onGridColumnsChange?.(updatedColumns);
|
|
2159
2985
|
},
|
|
2160
2986
|
onLinkCreate: (payload) => {
|
|
2161
|
-
|
|
2987
|
+
const sourceTask = this.#findTask(payload.sourceTaskId);
|
|
2988
|
+
const targetTask = this.#findTask(payload.targetTaskId);
|
|
2989
|
+
if (sourceTask !== void 0 && targetTask !== void 0) this.#callbacks.onLinkCreate?.({
|
|
2990
|
+
type: "FS",
|
|
2991
|
+
sourceTask,
|
|
2992
|
+
targetTask
|
|
2993
|
+
});
|
|
2994
|
+
},
|
|
2995
|
+
onLinkClick: (payload) => {
|
|
2996
|
+
this.#callbacks.onLinkClick?.({ link: payload });
|
|
2997
|
+
},
|
|
2998
|
+
onLinkDblClick: (payload) => {
|
|
2999
|
+
this.#callbacks.onLinkDblClick?.({ link: payload });
|
|
2162
3000
|
}
|
|
2163
3001
|
};
|
|
2164
3002
|
this.#buildDom();
|
|
@@ -2167,25 +3005,96 @@ var GanttChart = class {
|
|
|
2167
3005
|
this.#applyTheme();
|
|
2168
3006
|
this.#applyResponsivePaneStyles();
|
|
2169
3007
|
this.#setupResizeObserver();
|
|
2170
|
-
this.#render();
|
|
2171
3008
|
}
|
|
3009
|
+
/**
|
|
3010
|
+
* Replaces the full dataset and re-renders.
|
|
3011
|
+
*
|
|
3012
|
+
* @param newInput - The new {@link GanttInput} to apply.
|
|
3013
|
+
* @throws {GanttError} When the instance has been destroyed.
|
|
3014
|
+
*/
|
|
2172
3015
|
update(newInput) {
|
|
2173
3016
|
this.#assertAlive();
|
|
2174
3017
|
validateLinkRefs(newInput.tasks, newInput.links);
|
|
2175
3018
|
detectCycles(newInput.tasks, newInput.links);
|
|
2176
|
-
this.#input = newInput;
|
|
2177
|
-
this.#taskIndex = buildTaskIndex(
|
|
2178
|
-
this.#
|
|
3019
|
+
this.#input = structuredClone(newInput);
|
|
3020
|
+
this.#taskIndex = buildTaskIndex(this.#input.tasks);
|
|
3021
|
+
this.#expandedIds = getInitialExpandedIds(this.#input.tasks);
|
|
3022
|
+
if (this.#rafPending && this.#rafId !== null) {
|
|
3023
|
+
cancelAnimationFrame(this.#rafId);
|
|
3024
|
+
this.#rafId = null;
|
|
3025
|
+
this.#rafPending = false;
|
|
3026
|
+
}
|
|
3027
|
+
this.#render();
|
|
2179
3028
|
}
|
|
2180
|
-
|
|
3029
|
+
/**
|
|
3030
|
+
* Merges the supplied options into the current configuration and re-renders
|
|
3031
|
+
* only the panes affected by the changed options.
|
|
3032
|
+
*
|
|
3033
|
+
* @param opts - A partial {@link GanttOptions} object. Only the keys present
|
|
3034
|
+
* in this parameter are updated; missing keys keep their
|
|
3035
|
+
* previous values.
|
|
3036
|
+
* @throws {GanttError} When the instance has been destroyed.
|
|
3037
|
+
*/
|
|
3038
|
+
setOptions(opts) {
|
|
2181
3039
|
this.#assertAlive();
|
|
2182
|
-
this.#
|
|
2183
|
-
this.#
|
|
2184
|
-
|
|
3040
|
+
Object.assign(this.#opts, opts);
|
|
3041
|
+
this.#scale = this.#opts.scale ?? "day";
|
|
3042
|
+
let columnsChanged = false;
|
|
3043
|
+
if (opts.locale !== void 0) {
|
|
3044
|
+
this.#locale = resolveChartLocale(opts.locale);
|
|
3045
|
+
if (this.#opts.gridColumns === void 0) {
|
|
3046
|
+
this.#columns = gridColumnDefaults(this.#locale);
|
|
3047
|
+
this.#leftPaneDefaultWidth = gridNaturalWidth(this.#columns);
|
|
3048
|
+
columnsChanged = true;
|
|
3049
|
+
}
|
|
3050
|
+
if (this.#opts.weekendDays === void 0) this.#weekendDays = normalizeWeekendDays(this.#locale.weekendDays);
|
|
3051
|
+
}
|
|
3052
|
+
if (opts.gridColumns !== void 0) {
|
|
3053
|
+
this.#columns = opts.gridColumns;
|
|
3054
|
+
this.#leftPaneDefaultWidth = this.#opts.leftPaneWidth ?? gridNaturalWidth(this.#columns);
|
|
3055
|
+
columnsChanged = true;
|
|
3056
|
+
}
|
|
3057
|
+
if (columnsChanged && this.#input !== null) this.#rebuildLeftPaneHeader();
|
|
3058
|
+
if (opts.leftPaneWidth !== void 0) this.#leftPaneDefaultWidth = opts.leftPaneWidth;
|
|
3059
|
+
if (opts.height !== void 0) {
|
|
3060
|
+
this.#height = opts.height;
|
|
3061
|
+
this.#root.style.height = `${this.#height}px`;
|
|
3062
|
+
}
|
|
3063
|
+
if (opts.timelineMinWidth !== void 0) {
|
|
3064
|
+
this.#timelineMinWidth = opts.timelineMinWidth;
|
|
3065
|
+
this.#rightPane.style.minWidth = `${this.#timelineMinWidth}px`;
|
|
3066
|
+
}
|
|
3067
|
+
if (opts.weekendDays !== void 0) this.#weekendDays = normalizeWeekendDays(opts.weekendDays);
|
|
3068
|
+
if (opts.specialDays !== void 0) this.#specialDaysByDate = buildSpecialDayIndex(opts.specialDays);
|
|
3069
|
+
if (opts.theme !== void 0) this.#applyTheme();
|
|
3070
|
+
const hasLayoutChange = opts.leftPaneWidth !== void 0 || opts.responsiveSplitPane !== void 0 || opts.mobileBreakpoint !== void 0 || opts.mobileLeftPaneMinWidth !== void 0 || opts.mobileLeftPaneMaxRatio !== void 0 || opts.timelineMinWidth !== void 0;
|
|
3071
|
+
if (hasLayoutChange) this.#applyResponsivePaneStyles();
|
|
3072
|
+
const hasLeftPaneChange = columnsChanged || opts.locale !== void 0;
|
|
3073
|
+
const hasRightPaneChange = opts.scale !== void 0 || opts.showWeekends !== void 0 || opts.weekendDays !== void 0 || opts.specialDays !== void 0 || opts.highlightLinkedDependenciesOnSelect !== void 0 || opts.linkCreationEnabled !== void 0 || opts.progressDragEnabled !== void 0 || opts.viewportStart !== void 0 || opts.viewportEnd !== void 0 || opts.locale !== void 0 || opts.timelineMinWidth !== void 0;
|
|
3074
|
+
if (!(hasLeftPaneChange || hasRightPaneChange || hasLayoutChange)) return;
|
|
3075
|
+
if (this.#rafPending && this.#rafId !== null) {
|
|
3076
|
+
cancelAnimationFrame(this.#rafId);
|
|
3077
|
+
this.#rafId = null;
|
|
3078
|
+
this.#rafPending = false;
|
|
3079
|
+
}
|
|
3080
|
+
if (hasLeftPaneChange && !hasRightPaneChange) this.#renderGrid();
|
|
3081
|
+
else if (!hasLeftPaneChange && hasRightPaneChange) this.#renderTimeline();
|
|
3082
|
+
else this.#render();
|
|
3083
|
+
}
|
|
3084
|
+
/**
|
|
3085
|
+
* Programmatically selects or deselects a task.
|
|
3086
|
+
*
|
|
3087
|
+
* @param id - The task ID to select, or `null` to clear the selection.
|
|
3088
|
+
* @throws {GanttError} When the instance has been destroyed.
|
|
3089
|
+
*/
|
|
2185
3090
|
select(id) {
|
|
2186
3091
|
this.#assertAlive();
|
|
2187
|
-
this.#selectedId =
|
|
2188
|
-
|
|
3092
|
+
if (id === null) this.#selectedId = null;
|
|
3093
|
+
else {
|
|
3094
|
+
const task = this.#input?.tasks.find((t) => t.id === id);
|
|
3095
|
+
if (task !== void 0) this.#callbacks.onTaskSelect?.({ task });
|
|
3096
|
+
this.#selectedId = id;
|
|
3097
|
+
}
|
|
2189
3098
|
if (this.#rafPending && this.#rafId !== null) {
|
|
2190
3099
|
cancelAnimationFrame(this.#rafId);
|
|
2191
3100
|
this.#rafId = null;
|
|
@@ -2193,6 +3102,11 @@ var GanttChart = class {
|
|
|
2193
3102
|
}
|
|
2194
3103
|
this.#render();
|
|
2195
3104
|
}
|
|
3105
|
+
/**
|
|
3106
|
+
* Collapses all expandable groups in the task tree.
|
|
3107
|
+
*
|
|
3108
|
+
* @throws {GanttError} When the instance has been destroyed.
|
|
3109
|
+
*/
|
|
2196
3110
|
collapseAll() {
|
|
2197
3111
|
this.#assertAlive();
|
|
2198
3112
|
this.#expandedIds.clear();
|
|
@@ -2203,10 +3117,15 @@ var GanttChart = class {
|
|
|
2203
3117
|
}
|
|
2204
3118
|
this.#render();
|
|
2205
3119
|
}
|
|
3120
|
+
/**
|
|
3121
|
+
* Expands all expandable groups in the task tree.
|
|
3122
|
+
*
|
|
3123
|
+
* @throws {GanttError} When the instance has been destroyed.
|
|
3124
|
+
*/
|
|
2206
3125
|
expandAll() {
|
|
2207
3126
|
this.#assertAlive();
|
|
2208
3127
|
this.#expandedIds.clear();
|
|
2209
|
-
for (const id of getExpandableTaskIds(this.#input.tasks)) this.#expandedIds.add(id);
|
|
3128
|
+
if (this.#input !== null) for (const id of getExpandableTaskIds(this.#input.tasks)) this.#expandedIds.add(id);
|
|
2210
3129
|
if (this.#rafPending && this.#rafId !== null) {
|
|
2211
3130
|
cancelAnimationFrame(this.#rafId);
|
|
2212
3131
|
this.#rafId = null;
|
|
@@ -2214,6 +3133,10 @@ var GanttChart = class {
|
|
|
2214
3133
|
}
|
|
2215
3134
|
this.#render();
|
|
2216
3135
|
}
|
|
3136
|
+
/**
|
|
3137
|
+
* Removes the chart DOM and internal listeners, rendering the instance
|
|
3138
|
+
* unusable. Subsequent calls to any public method will throw.
|
|
3139
|
+
*/
|
|
2217
3140
|
destroy() {
|
|
2218
3141
|
if (this.#destroyed) return;
|
|
2219
3142
|
this.#destroyed = true;
|
|
@@ -2222,13 +3145,15 @@ var GanttChart = class {
|
|
|
2222
3145
|
else window.removeEventListener("resize", this.#applyResponsivePaneStyles);
|
|
2223
3146
|
if (this.#rafId !== null) cancelAnimationFrame(this.#rafId);
|
|
2224
3147
|
this.#columnResizeCleanup();
|
|
2225
|
-
for (const { cleanupDrag, cleanupLinkHandles } of this.#rightPaneRefs.barRegistry.values()) {
|
|
3148
|
+
for (const { cleanupDrag, cleanupLinkHandles, cleanupProgressDrag } of this.#rightPaneRefs.barRegistry.values()) {
|
|
2226
3149
|
cleanupDrag();
|
|
2227
3150
|
cleanupLinkHandles?.();
|
|
3151
|
+
cleanupProgressDrag?.();
|
|
2228
3152
|
}
|
|
2229
3153
|
clearChildren(this.#container);
|
|
2230
3154
|
}
|
|
2231
3155
|
#patchTask(id, patch) {
|
|
3156
|
+
if (this.#input === null) return;
|
|
2232
3157
|
const index = this.#taskIndex.get(id);
|
|
2233
3158
|
if (index === void 0) return;
|
|
2234
3159
|
const target = this.#input.tasks[index];
|
|
@@ -2238,15 +3163,16 @@ var GanttChart = class {
|
|
|
2238
3163
|
...patch
|
|
2239
3164
|
};
|
|
2240
3165
|
}
|
|
3166
|
+
#findTask(id) {
|
|
3167
|
+
return this.#input?.tasks.find((t) => t.id === id);
|
|
3168
|
+
}
|
|
2241
3169
|
#handleGridClick = (payload) => {
|
|
2242
3170
|
const now = Date.now();
|
|
2243
3171
|
const prev = this.#lastGridClick;
|
|
2244
3172
|
if (prev !== null && prev.id === payload.id && now - prev.atMs <= 350) {
|
|
2245
3173
|
this.#lastGridClick = null;
|
|
2246
|
-
this.#cbs.
|
|
3174
|
+
this.#cbs.onTaskDoubleClick?.({
|
|
2247
3175
|
id: payload.id,
|
|
2248
|
-
source: "grid",
|
|
2249
|
-
trigger: "double_click",
|
|
2250
3176
|
task: payload.task
|
|
2251
3177
|
});
|
|
2252
3178
|
return;
|
|
@@ -2255,7 +3181,7 @@ var GanttChart = class {
|
|
|
2255
3181
|
id: payload.id,
|
|
2256
3182
|
atMs: now
|
|
2257
3183
|
};
|
|
2258
|
-
this.#cbs.
|
|
3184
|
+
this.#cbs.onTaskSelect?.(payload.id);
|
|
2259
3185
|
};
|
|
2260
3186
|
#onScroll = () => {
|
|
2261
3187
|
({scrollTop: this.#scrollTop} = this.#scrollEl);
|
|
@@ -2278,13 +3204,13 @@ var GanttChart = class {
|
|
|
2278
3204
|
this.#leftPane.style.maxWidth = `${computedWidth}px`;
|
|
2279
3205
|
this.#rightPane.style.minWidth = `${this.#timelineMinWidth}px`;
|
|
2280
3206
|
};
|
|
2281
|
-
#computeState() {
|
|
2282
|
-
const allRows = flattenTree(buildTaskTree(
|
|
2283
|
-
const [vpStart, vpEnd] = this.#opts.viewportStart !== void 0 && this.#opts.viewportEnd !== void 0 ? [this.#opts.viewportStart, this.#opts.viewportEnd] : deriveViewport(allRows,
|
|
3207
|
+
#computeState(input) {
|
|
3208
|
+
const allRows = flattenTree(buildTaskTree(input.tasks), this.#expandedIds);
|
|
3209
|
+
const [vpStart, vpEnd] = this.#opts.viewportStart !== void 0 && this.#opts.viewportEnd !== void 0 ? [this.#opts.viewportStart, this.#opts.viewportEnd] : deriveViewport(allRows, 48);
|
|
2284
3210
|
const mapper = createPixelMapper(this.#scale, vpStart);
|
|
2285
3211
|
const totalWidth = Math.ceil(mapper.toX(vpEnd)) + 1;
|
|
2286
3212
|
const layouts = computeLayout(allRows, mapper);
|
|
2287
|
-
const links = routeLinks(
|
|
3213
|
+
const links = routeLinks(input.links, layouts);
|
|
2288
3214
|
const containerH = this.#height - HEADER_H;
|
|
2289
3215
|
const rowCount = allRows.length;
|
|
2290
3216
|
const startIndex = Math.max(0, Math.floor(this.#scrollTop / ROW_HEIGHT) - OVERSCAN);
|
|
@@ -2292,10 +3218,11 @@ var GanttChart = class {
|
|
|
2292
3218
|
const paddingTop = startIndex * ROW_HEIGHT;
|
|
2293
3219
|
const paddingBottom = Math.max(0, (rowCount - 1 - endIndex) * ROW_HEIGHT);
|
|
2294
3220
|
return {
|
|
2295
|
-
input
|
|
3221
|
+
input,
|
|
2296
3222
|
scale: this.#scale,
|
|
2297
3223
|
highlightLinkedDependenciesOnSelect: this.#opts.highlightLinkedDependenciesOnSelect ?? false,
|
|
2298
3224
|
linkCreationEnabled: this.#opts.linkCreationEnabled ?? false,
|
|
3225
|
+
progressDragEnabled: this.#opts.progressDragEnabled ?? false,
|
|
2299
3226
|
expandedIds: this.#expandedIds,
|
|
2300
3227
|
selectedId: this.#selectedId,
|
|
2301
3228
|
scrollTop: this.#scrollTop,
|
|
@@ -2318,23 +3245,51 @@ var GanttChart = class {
|
|
|
2318
3245
|
}
|
|
2319
3246
|
#render = () => {
|
|
2320
3247
|
this.#rafPending = false;
|
|
2321
|
-
const
|
|
3248
|
+
const input = this.#input;
|
|
3249
|
+
if (input === null) return;
|
|
3250
|
+
const state = this.#computeState(input);
|
|
2322
3251
|
renderTimeHeader(this.#rightHeader, state);
|
|
3252
|
+
this.#renderGridInternal(state);
|
|
3253
|
+
renderRightPane(this.#rightPaneRefs, state, this.#cbs);
|
|
3254
|
+
};
|
|
3255
|
+
#renderGrid = () => {
|
|
3256
|
+
this.#rafPending = false;
|
|
3257
|
+
const input = this.#input;
|
|
3258
|
+
if (input === null) return;
|
|
3259
|
+
this.#renderGridInternal(this.#computeState(input));
|
|
3260
|
+
};
|
|
3261
|
+
#renderGridInternal(state) {
|
|
2323
3262
|
renderLeftPane(this.#leftBody, state, {
|
|
2324
3263
|
onToggle: (id) => {
|
|
2325
3264
|
if (this.#expandedIds.has(id)) this.#expandedIds.delete(id);
|
|
2326
3265
|
else this.#expandedIds.add(id);
|
|
2327
3266
|
this.#scheduleRender();
|
|
2328
3267
|
},
|
|
2329
|
-
|
|
3268
|
+
onTaskSelect: (id) => this.#cbs.onTaskSelect?.(id),
|
|
2330
3269
|
onRowClick: (payload) => {
|
|
2331
3270
|
this.#handleGridClick(payload);
|
|
2332
3271
|
},
|
|
2333
|
-
|
|
2334
|
-
|
|
3272
|
+
onTaskDoubleClick: (payload) => this.#cbs.onTaskDoubleClick?.(payload),
|
|
3273
|
+
onTaskAdd: (id) => this.#cbs.onTaskAdd?.(id)
|
|
2335
3274
|
}, this.#columns);
|
|
3275
|
+
}
|
|
3276
|
+
#renderTimeline = () => {
|
|
3277
|
+
this.#rafPending = false;
|
|
3278
|
+
const input = this.#input;
|
|
3279
|
+
if (input === null) return;
|
|
3280
|
+
const state = this.#computeState(input);
|
|
3281
|
+
renderTimeHeader(this.#rightHeader, state);
|
|
2336
3282
|
renderRightPane(this.#rightPaneRefs, state, this.#cbs);
|
|
2337
3283
|
};
|
|
3284
|
+
#rebuildLeftPaneHeader() {
|
|
3285
|
+
this.#columnResizeCleanup();
|
|
3286
|
+
clearChildren(this.#leftHeader);
|
|
3287
|
+
const headerEl = buildLeftPaneHeader(this.#columns);
|
|
3288
|
+
this.#leftHeader.append(headerEl);
|
|
3289
|
+
this.#columnResizeCleanup = setupColumnResize(headerEl, this.#leftBody, this.#columns, (updated) => {
|
|
3290
|
+
this.#cbs.onGridColumnsChange?.(updated);
|
|
3291
|
+
});
|
|
3292
|
+
}
|
|
2338
3293
|
#scheduleRender() {
|
|
2339
3294
|
if (this.#rafPending || this.#destroyed) return;
|
|
2340
3295
|
this.#rafPending = true;
|
|
@@ -2390,6 +3345,7 @@ var GanttChart = class {
|
|
|
2390
3345
|
const headerEl = buildLeftPaneHeader(this.#columns);
|
|
2391
3346
|
leftHeader.append(headerEl);
|
|
2392
3347
|
leftPane.append(leftHeader);
|
|
3348
|
+
this.#leftHeader = leftHeader;
|
|
2393
3349
|
const leftBody = el("div");
|
|
2394
3350
|
leftPane.append(leftBody);
|
|
2395
3351
|
this.#leftBody = leftBody;
|
|
@@ -2437,12 +3393,14 @@ var GanttChart = class {
|
|
|
2437
3393
|
#wireEvents() {
|
|
2438
3394
|
this.#rightPaneRefs.absoluteLayer.addEventListener("click", (event) => {
|
|
2439
3395
|
if (event.target.closest(".gantt-bar, .gantt-milestone, .gantt-resize-handle")) return;
|
|
2440
|
-
this.#
|
|
3396
|
+
this.#selectedId = null;
|
|
3397
|
+
this.#scheduleRender();
|
|
2441
3398
|
});
|
|
2442
3399
|
this.#root.addEventListener("keydown", (event) => {
|
|
2443
3400
|
if (event.key === "Escape" && this.#selectedId !== null) {
|
|
2444
3401
|
event.preventDefault();
|
|
2445
|
-
this.#
|
|
3402
|
+
this.#selectedId = null;
|
|
3403
|
+
this.#scheduleRender();
|
|
2446
3404
|
}
|
|
2447
3405
|
});
|
|
2448
3406
|
this.#scrollEl.addEventListener("scroll", this.#onScroll);
|
|
@@ -2457,6 +3415,6 @@ var GanttChart = class {
|
|
|
2457
3415
|
}
|
|
2458
3416
|
};
|
|
2459
3417
|
//#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,
|
|
3418
|
+
export { BAR_HEIGHT, BAR_Y_OFFSET, CHART_LOCALE_EN_US, DEFAULT_GRID_COLUMNS, DENSITY, EN_US_LABELS, GRID_COLUMN_FR_MIN_WIDTH, GanttChart, GanttError, GanttInputSchema, LinkSchema, LinkTypeSchema, MILESTONE_HALF, MILESTONE_SIZE, ROW_HEIGHT, SCALE_CONFIGS, SpecialDayKindSchema, SpecialDaySchema, TaskKindSchema, TaskSchema, addDays, addHours, buildTaskTree, computeLayout, createPixelMapper, deriveViewport, deriveWeekNumbering, deriveWeekStartsOn, deriveWeekendDays, detectCycles, diffDays, diffHours, flattenTree, formatLabel, formatWeekNumber, gridColumnDefaults, gridNaturalWidth, gridTemplateColumns, isParent, parseDate, parseGanttInput, resolveChartLocale, routeLinks, validateLinkRefs, visibleColumns };
|
|
2461
3419
|
|
|
2462
3420
|
//# sourceMappingURL=index.mjs.map
|