@profpowell/vb-project-planning 0.2.1 → 0.2.2
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/README.md +5 -2
- package/dist/vb-project-planning.css +56 -2
- package/dist/vb-project-planning.js +3188 -2544
- package/dist/vb-project-planning.min.css +1 -1
- package/dist/vb-project-planning.min.js +221 -197
- package/package.json +7 -4
- package/src/lib/board-surface.js +158 -0
- package/src/lib/bundle-registry.js +2 -75
- package/src/lib/dates.js +125 -0
- package/src/lib/escape.js +34 -0
- package/src/lib/hash.js +16 -0
- package/src/lib/portrait-url.js +3 -29
- package/src/lib/src-loader.js +78 -0
- package/src/utils/copy-init.js +56 -17
- package/src/web-components/_ux-base.js +409 -24
- package/src/web-components/adr-wc/README.md +18 -16
- package/src/web-components/adr-wc/api.json +7 -5
- package/src/web-components/adr-wc/logic.js +50 -136
- package/src/web-components/burndown-chart/api.json +8 -6
- package/src/web-components/burndown-chart/logic.js +75 -39
- package/src/web-components/empathy-map/api.json +13 -13
- package/src/web-components/empathy-map/logic.js +161 -168
- package/src/web-components/empathy-map/static.html +3 -3
- package/src/web-components/empathy-map/styles.js +1 -0
- package/src/web-components/gantt-chart/README.md +12 -6
- package/src/web-components/gantt-chart/api.json +23 -7
- package/src/web-components/gantt-chart/logic.js +456 -431
- package/src/web-components/gantt-chart/styles.css +9 -1
- package/src/web-components/impact-effort/api.json +9 -7
- package/src/web-components/impact-effort/logic.js +238 -209
- package/src/web-components/impact-effort/static.html +12 -4
- package/src/web-components/iron-triangle/_capacity.js +0 -18
- package/src/web-components/kanban-board/api.json +8 -6
- package/src/web-components/kanban-board/logic.js +289 -333
- package/src/web-components/product-roadmap/api.json +14 -13
- package/src/web-components/product-roadmap/logic.js +393 -246
- package/src/web-components/product-roadmap/styles.css +40 -0
- package/src/web-components/quadrant-grid/api.json +9 -4
- package/src/web-components/quadrant-grid/logic.js +80 -12
- package/src/web-components/quadrant-grid/styles.css +7 -0
- package/src/web-components/quality-target/_quality-utils.js +2 -46
- package/src/web-components/review-surface/README.md +9 -5
- package/src/web-components/review-surface/api.json +10 -8
- package/src/web-components/review-surface/logic.js +349 -218
- package/src/web-components/risk-register/api.json +1 -6
- package/src/web-components/risk-register/logic.js +71 -24
- package/src/web-components/story-map/api.json +8 -6
- package/src/web-components/story-map/logic.js +280 -281
- package/src/web-components/story-map/static.html +15 -5
- package/src/web-components/traceability-matrix/api.json +10 -3
- package/src/web-components/traceability-matrix/logic.js +112 -63
- package/src/web-components/traceability-matrix/styles.css +14 -1
- package/src/web-components/user-journey/api.json +25 -24
- package/src/web-components/user-journey/logic.js +76 -127
- package/src/web-components/user-journey/static.html +2 -2
- package/src/web-components/user-persona/api.json +15 -14
- package/src/web-components/user-persona/logic.js +71 -117
- package/src/web-components/user-persona/styles.js +1 -0
- package/src/web-components/user-story/api.json +13 -11
- package/src/web-components/user-story/logic.js +164 -195
- package/src/web-components/user-story/styles.js +28 -1
- package/src/web-components/work-item/README.md +3 -3
- package/src/web-components/work-item/api.json +13 -11
- package/src/web-components/work-item/logic.js +58 -154
|
@@ -7,6 +7,17 @@
|
|
|
7
7
|
* Without JS: renders as a readable data table with progress bars.
|
|
8
8
|
* With JS: visual timeline with bars, groups, milestones, and today line.
|
|
9
9
|
*
|
|
10
|
+
* Data sources, in order of precedence: the `.tasks` property, the `src`
|
|
11
|
+
* JSON URL, then the child <table>. `.tasks` assigned before connect means
|
|
12
|
+
* the initial `src` is not fetched; assigning `.tasks` while a src request
|
|
13
|
+
* is in flight aborts it. A later change to `src` loads that URL (the most
|
|
14
|
+
* recent source wins). Dates are calendar days in local time
|
|
15
|
+
* (src/lib/dates.js), so `2026-04-01` shows as Apr 1 in every time zone.
|
|
16
|
+
*
|
|
17
|
+
* Progress (`.tasks` and `src` JSON): a value greater than 1 is a
|
|
18
|
+
* percentage (0–100); a value from 0 to 1 is a fraction. A table's
|
|
19
|
+
* <progress> is read as value / max. `.tasks` reads back as a fraction.
|
|
20
|
+
*
|
|
10
21
|
* @attr {string} title - Chart heading
|
|
11
22
|
* @attr {string} src - URL to JSON task data
|
|
12
23
|
* @attr {string} view - Timeline granularity: auto|day|week|month|quarter
|
|
@@ -26,55 +37,110 @@
|
|
|
26
37
|
* @attr {string} data-story-ids - Comma-separated linked user-story IDs
|
|
27
38
|
* @attr {string} data-item-ids - Comma-separated linked work-item IDs
|
|
28
39
|
*
|
|
29
|
-
* @fires gantt-chart:ready -
|
|
30
|
-
* @fires gantt-chart:task-click - Task bar
|
|
40
|
+
* @fires gantt-chart:ready - Once per element: after the first connect render with tasks, or else the first src load
|
|
41
|
+
* @fires gantt-chart:task-click - Task bar or milestone activated (click, Enter, Space)
|
|
42
|
+
* @fires gantt-chart:tasks-changed - After `.tasks` assignment or a src load
|
|
31
43
|
*/
|
|
32
44
|
|
|
33
45
|
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
34
46
|
import { VBElement } from '../../lib/vb-element.js';
|
|
47
|
+
import { viewTransitionSwap } from '../../lib/vb-view-transition.js';
|
|
48
|
+
import { SrcLoader } from '../../lib/src-loader.js';
|
|
49
|
+
import {
|
|
50
|
+
parseISODate, toISODate, addDays, addMonths, daysBetween,
|
|
51
|
+
startOfDay, startOfMonth, startOfQuarter, formatDate,
|
|
52
|
+
} from '../../lib/dates.js';
|
|
53
|
+
|
|
54
|
+
/* ── Date helpers (calendar days) ── */
|
|
55
|
+
|
|
56
|
+
/** @param {number} rangeDays */
|
|
57
|
+
function pickFormatter(rangeDays) {
|
|
58
|
+
if (rangeDays <= 90) {
|
|
59
|
+
return (d) => formatDate(d, { month: 'short', day: 'numeric' });
|
|
60
|
+
}
|
|
61
|
+
if (rangeDays <= 365) {
|
|
62
|
+
return (d) => formatDate(d, { month: 'short', year: '2-digit' });
|
|
63
|
+
}
|
|
64
|
+
return (d) => formatDate(d, { year: 'numeric' });
|
|
65
|
+
}
|
|
35
66
|
|
|
36
|
-
|
|
67
|
+
/** @param {number} rangeDays */
|
|
68
|
+
function resolveView(rangeDays) {
|
|
69
|
+
if (rangeDays <= 21) return 'day';
|
|
70
|
+
if (rangeDays <= 90) return 'week';
|
|
71
|
+
if (rangeDays <= 365) return 'month';
|
|
72
|
+
return 'quarter';
|
|
73
|
+
}
|
|
37
74
|
|
|
38
|
-
const
|
|
39
|
-
const WEEK = DAY * 7;
|
|
40
|
-
const MONTH = DAY * 30;
|
|
41
|
-
const QUARTER = DAY * 91;
|
|
42
|
-
const YEAR = DAY * 365;
|
|
75
|
+
const STEP_DAYS = { day: 1, week: 7, month: 30, quarter: 91 };
|
|
43
76
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return d.getTime();
|
|
77
|
+
/** Long-form date for labels, e.g. "Apr 1, 2026". @param {number} ts */
|
|
78
|
+
function longDate(ts) {
|
|
79
|
+
return formatDate(new Date(ts), { year: 'numeric', month: 'short', day: 'numeric' });
|
|
48
80
|
}
|
|
49
81
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return (d) => d.toLocaleDateString(undefined, { year: 'numeric' });
|
|
82
|
+
const STATUS_LABELS = {
|
|
83
|
+
'not-started': 'Not started',
|
|
84
|
+
'in-progress': 'In progress',
|
|
85
|
+
done: 'Done',
|
|
86
|
+
blocked: 'Blocked',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** @param {string} s */
|
|
90
|
+
function statusLabel(s) {
|
|
91
|
+
return STATUS_LABELS[s] || (s.charAt(0).toUpperCase() + s.slice(1)).replace(/-/g, ' ');
|
|
61
92
|
}
|
|
62
93
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
if (
|
|
67
|
-
return '
|
|
94
|
+
/** Split "a, b" or pass arrays through. @param {unknown} v @returns {string[]} */
|
|
95
|
+
function idList(v) {
|
|
96
|
+
if (Array.isArray(v)) return v.map(String);
|
|
97
|
+
if (v == null || v === '') return [];
|
|
98
|
+
return String(v).split(',').map((s) => s.trim()).filter(Boolean);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One progress convention everywhere: > 1 is a percentage, 0–1 a fraction.
|
|
103
|
+
* Returns a fraction clamped to [0, 1].
|
|
104
|
+
* @param {unknown} v
|
|
105
|
+
*/
|
|
106
|
+
function normalizeProgress(v) {
|
|
107
|
+
const n = Number(v);
|
|
108
|
+
if (!Number.isFinite(n) || n <= 0) return 0;
|
|
109
|
+
return Math.min(1, n > 1 ? n / 100 : n);
|
|
68
110
|
}
|
|
69
111
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Normalize author/property/JSON task records. Start/end become
|
|
114
|
+
* local-midnight timestamps; tasks without a valid start are dropped.
|
|
115
|
+
* @param {Array<any>} list
|
|
116
|
+
*/
|
|
117
|
+
function normalizeTasks(list) {
|
|
118
|
+
const out = [];
|
|
119
|
+
(list || []).forEach((t, i) => {
|
|
120
|
+
const start = parseISODate(t?.start);
|
|
121
|
+
if (!start) return;
|
|
122
|
+
const end = parseISODate(t.end) ?? start;
|
|
123
|
+
out.push({
|
|
124
|
+
id: t.id != null && t.id !== '' ? String(t.id) : `gc-task-${i}`,
|
|
125
|
+
name: t.name ?? `Task ${i + 1}`,
|
|
126
|
+
start: start.getTime(),
|
|
127
|
+
end: Math.max(end.getTime(), start.getTime()),
|
|
128
|
+
progress: normalizeProgress(t.progress),
|
|
129
|
+
group: t.group || null,
|
|
130
|
+
depends: idList(t.depends),
|
|
131
|
+
status: t.status || null,
|
|
132
|
+
assignee: t.assignee || null,
|
|
133
|
+
milestone: !!t.milestone,
|
|
134
|
+
color: t.color || null,
|
|
135
|
+
storyIds: idList(t.storyIds),
|
|
136
|
+
itemIds: idList(t.itemIds),
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
return out;
|
|
76
140
|
}
|
|
77
141
|
|
|
142
|
+
let instanceCounter = 0;
|
|
143
|
+
|
|
78
144
|
/* ── Component ─────────────────────────────────────── */
|
|
79
145
|
|
|
80
146
|
class GanttChart extends VBElement {
|
|
@@ -82,233 +148,212 @@ class GanttChart extends VBElement {
|
|
|
82
148
|
return ['src', 'title', 'view', 'show-today', 'show-progress', 'show-dependencies', 'compact'];
|
|
83
149
|
}
|
|
84
150
|
|
|
151
|
+
#uid = `gc-${++instanceCounter}`;
|
|
85
152
|
/** @type {HTMLDivElement|null} */
|
|
86
153
|
#container = null;
|
|
87
154
|
/** @type {HTMLDivElement|null} */
|
|
88
155
|
#liveRegion = null;
|
|
89
156
|
/** @type {HTMLElement|null} */
|
|
90
157
|
#titleEl = null;
|
|
91
|
-
/** @type {Array<
|
|
158
|
+
/** @type {Array<any>} */
|
|
92
159
|
#tasks = [];
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
#
|
|
160
|
+
/**
|
|
161
|
+
* Where #tasks came from. 'table' re-parses the author's <table> on each
|
|
162
|
+
* connect; 'property' and 'src' keep #tasks and generate the SR table.
|
|
163
|
+
* @type {'table' | 'property' | 'src'}
|
|
164
|
+
*/
|
|
165
|
+
#source = 'table';
|
|
166
|
+
/** One in-flight src request; `url` guards against refetching on reconnect. */
|
|
167
|
+
#loader = new SrcLoader();
|
|
168
|
+
#readyFired = false;
|
|
169
|
+
#internalAttr = false;
|
|
170
|
+
/** Local-midnight Date of the first day in range. */
|
|
171
|
+
#rangeStart = new Date(0);
|
|
172
|
+
/** Calendar days spanned by the range (≥ 1). */
|
|
173
|
+
#rangeDays = 1;
|
|
174
|
+
|
|
175
|
+
constructor() {
|
|
176
|
+
super();
|
|
177
|
+
/* A `.tasks` set before the element was defined lands as an own
|
|
178
|
+
property and would shadow the accessor: re-route it. */
|
|
179
|
+
if (Object.prototype.hasOwnProperty.call(this, 'tasks')) {
|
|
180
|
+
const value = /** @type {any} */ (this).tasks;
|
|
181
|
+
delete (/** @type {any} */ (this)).tasks;
|
|
182
|
+
this.tasks = value;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
99
185
|
|
|
100
186
|
setup() {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
// 1. Parse tasks from table
|
|
105
|
-
this.#tasks = this.#parseTasks(table);
|
|
106
|
-
if (this.#tasks.length === 0) return false;
|
|
107
|
-
|
|
108
|
-
// 2. Calculate date range
|
|
109
|
-
this.#rangeStart = Math.min(...this.#tasks.map(t => t.start));
|
|
110
|
-
this.#rangeEnd = Math.max(...this.#tasks.map(t => t.end));
|
|
111
|
-
this.#rangeTotal = this.#rangeEnd - this.#rangeStart;
|
|
112
|
-
if (this.#rangeTotal <= 0) this.#rangeTotal = DAY; // minimum 1 day
|
|
113
|
-
|
|
114
|
-
// 3. Build enhanced DOM
|
|
115
|
-
this.#build(table);
|
|
187
|
+
this.listen(this, 'click', this.#onClick);
|
|
188
|
+
this.listen(this, 'keydown', this.#onKeyDown);
|
|
116
189
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
190
|
+
if (this.#source === 'table') {
|
|
191
|
+
const table = this.querySelector(':scope > table');
|
|
192
|
+
this.#tasks = table ? this.#parseTasks(table) : [];
|
|
193
|
+
}
|
|
194
|
+
this.#render();
|
|
195
|
+
if (this.#tasks.length) this.#dispatchReady();
|
|
196
|
+
|
|
197
|
+
/* src loads here as well as in attributeChangedCallback, so
|
|
198
|
+
createElement + setAttribute('src') + append fetches (once).
|
|
199
|
+
`.tasks` takes precedence: once assigned, the initial src is skipped. */
|
|
200
|
+
const src = this.getAttribute('src');
|
|
201
|
+
if (src && this.#source !== 'property' && src !== this.#loader.url) this.#loadSrc(src);
|
|
128
202
|
return true;
|
|
129
203
|
}
|
|
130
204
|
|
|
131
205
|
teardown() {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
// Restore the table visibility
|
|
137
|
-
const table = this.querySelector('table.gc-sr-only');
|
|
138
|
-
if (table) table.classList.remove('gc-sr-only');
|
|
139
|
-
|
|
140
|
-
this.#tasks = [];
|
|
206
|
+
this.#removeChrome();
|
|
207
|
+
const table = this.querySelector(':scope > table.gc-sr-only');
|
|
208
|
+
if (table && !table.hasAttribute('data-gc-generated')) table.classList.remove('gc-sr-only');
|
|
141
209
|
}
|
|
142
210
|
|
|
143
|
-
/** @type {number} */
|
|
144
|
-
static #vtCounter = 0;
|
|
145
|
-
|
|
146
211
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
147
|
-
if (oldValue === newValue ||
|
|
212
|
+
if (oldValue === newValue || this.#internalAttr) return;
|
|
213
|
+
if (!this.isConnected || !this.hasAttribute('data-upgraded')) return; // setup() reads them
|
|
148
214
|
if (name === 'src') {
|
|
149
|
-
this
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Tear down and re-setup, wrapped in a View Transition so the swap
|
|
157
|
-
* crossfades instead of flashing. Same pattern as diagram-wc and
|
|
158
|
-
* site-map.
|
|
159
|
-
*/
|
|
160
|
-
#refresh() {
|
|
161
|
-
const swap = () => {
|
|
162
|
-
this.teardown();
|
|
163
|
-
this.removeAttribute('data-upgraded');
|
|
164
|
-
this.setup();
|
|
165
|
-
};
|
|
166
|
-
if (this.hasAttribute('data-upgraded')
|
|
167
|
-
&& 'startViewTransition' in document
|
|
168
|
-
&& !matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
169
|
-
const name = `gc-vt-${++GanttChart.#vtCounter}`;
|
|
170
|
-
this.style.viewTransitionName = name;
|
|
171
|
-
const tx = document.startViewTransition(swap);
|
|
172
|
-
tx.finished.finally(() => { this.style.viewTransitionName = ''; });
|
|
173
|
-
} else {
|
|
174
|
-
swap();
|
|
215
|
+
if (newValue) this.#loadSrc(newValue);
|
|
216
|
+
else this.#loader.cancel();
|
|
217
|
+
return;
|
|
175
218
|
}
|
|
219
|
+
viewTransitionSwap(this, () => this.#render(), 'gc-vt');
|
|
176
220
|
}
|
|
177
221
|
|
|
178
222
|
// ── Data API (HTML-first / JS-first dual contract) ──────────────
|
|
179
223
|
|
|
180
224
|
/**
|
|
181
|
-
* The current task list.
|
|
182
|
-
*
|
|
183
|
-
* Each entry: { id, name, start, end, progress?, group?, depends?,
|
|
184
|
-
* status?, assignee?, milestone?, color?, storyIds?, itemIds? }.
|
|
225
|
+
* The current task list (normalized). start/end are local-midnight
|
|
226
|
+
* timestamps; progress is a 0–1 fraction.
|
|
185
227
|
*/
|
|
186
228
|
get tasks() { return this.#tasks; }
|
|
187
229
|
|
|
188
230
|
/**
|
|
189
|
-
* Replace the task set and re-render.
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* Note: v1 is record-shaped (full rebuild on assignment). Per-bar
|
|
193
|
-
* preservation across diffs is on the roadmap for when drag-resize
|
|
194
|
-
* state preservation becomes a felt need.
|
|
231
|
+
* Replace the task set and re-render. start/end accept YYYY-MM-DD
|
|
232
|
+
* strings, Dates or timestamps; progress accepts 0–1 or 0–100 (> 1).
|
|
233
|
+
* The visually hidden table is regenerated from the new tasks.
|
|
195
234
|
*/
|
|
196
235
|
set tasks(value) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
end: typeof t.end === 'number' ? t.end : (t.end != null ? toTimestamp(t.end) : (typeof t.start === 'number' ? t.start : toTimestamp(t.start))),
|
|
202
|
-
progress: t.progress ?? 0,
|
|
203
|
-
group: t.group ?? null,
|
|
204
|
-
depends: Array.isArray(t.depends) ? t.depends : (t.depends ? String(t.depends).split(',').map(s => s.trim()) : []),
|
|
205
|
-
status: t.status ?? null,
|
|
206
|
-
assignee: t.assignee ?? null,
|
|
207
|
-
milestone: !!t.milestone,
|
|
208
|
-
color: t.color ?? null,
|
|
209
|
-
storyIds: t.storyIds ?? [],
|
|
210
|
-
itemIds: t.itemIds ?? [],
|
|
211
|
-
}));
|
|
212
|
-
|
|
213
|
-
// Tear down existing chrome and re-render from the new task list.
|
|
214
|
-
if (this.#container || this.#titleEl || this.#liveRegion) {
|
|
215
|
-
if (this.#titleEl) { this.#titleEl.remove(); this.#titleEl = null; }
|
|
216
|
-
if (this.#container) { this.#container.remove(); this.#container = null; }
|
|
217
|
-
if (this.#liveRegion) { this.#liveRegion.remove(); this.#liveRegion = null; }
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
this.#tasks = next;
|
|
221
|
-
|
|
222
|
-
if (next.length > 0) {
|
|
223
|
-
this.#rangeStart = Math.min(...next.map(t => t.start));
|
|
224
|
-
this.#rangeEnd = Math.max(...next.map(t => t.end));
|
|
225
|
-
this.#rangeTotal = this.#rangeEnd - this.#rangeStart;
|
|
226
|
-
if (this.#rangeTotal <= 0) this.#rangeTotal = DAY;
|
|
227
|
-
|
|
228
|
-
// Source <table> may not exist (JS-first scaffold). Find or create one
|
|
229
|
-
// for the build path; we hide it via gc-sr-only either way.
|
|
230
|
-
let table = this.querySelector(':scope > table');
|
|
231
|
-
if (!table) {
|
|
232
|
-
table = document.createElement('table');
|
|
233
|
-
table.classList.add('gc-sr-only');
|
|
234
|
-
this.appendChild(table);
|
|
235
|
-
}
|
|
236
|
-
this.#build(table);
|
|
237
|
-
}
|
|
238
|
-
|
|
236
|
+
this.#loader.cancel(); // .tasks wins over a pending src response
|
|
237
|
+
this.#tasks = normalizeTasks(value);
|
|
238
|
+
this.#source = 'property';
|
|
239
|
+
if (this.hasAttribute('data-upgraded')) this.#render();
|
|
239
240
|
this.dispatchEvent(new CustomEvent('gantt-chart:tasks-changed', {
|
|
240
|
-
detail: { tasks:
|
|
241
|
+
detail: { tasks: this.#tasks, source: 'property' },
|
|
241
242
|
bubbles: true,
|
|
242
243
|
}));
|
|
243
244
|
}
|
|
244
245
|
|
|
245
246
|
/* ── Table parsing ─────────────────────────────── */
|
|
246
247
|
|
|
248
|
+
/** @param {Element} table */
|
|
247
249
|
#parseTasks(table) {
|
|
248
250
|
const rows = table.querySelectorAll('tbody tr');
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
+
const raw = [];
|
|
251
252
|
for (let i = 0; i < rows.length; i++) {
|
|
252
253
|
const row = /** @type {HTMLTableRowElement} */ (rows[i]);
|
|
253
254
|
const times = row.querySelectorAll('time[datetime]');
|
|
254
255
|
if (times.length < 2 && !row.hasAttribute('data-milestone')) continue;
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const endStr = times.length > 1 ? times[1].getAttribute('datetime') : startStr;
|
|
258
|
-
if (!startStr) continue;
|
|
259
|
-
|
|
256
|
+
const start = times[0]?.getAttribute('datetime');
|
|
257
|
+
if (!start) continue;
|
|
260
258
|
const progressEl = row.querySelector('progress');
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const name = row.cells[0]?.textContent?.trim() || `Task ${i + 1}`;
|
|
264
|
-
|
|
265
|
-
tasks.push({
|
|
259
|
+
raw.push({
|
|
266
260
|
id: row.dataset.taskId || `gc-task-${i}`,
|
|
267
|
-
name
|
|
268
|
-
start
|
|
269
|
-
end:
|
|
270
|
-
progress
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
261
|
+
name: row.cells[0]?.textContent?.trim() || `Task ${i + 1}`,
|
|
262
|
+
start,
|
|
263
|
+
end: times.length > 1 ? times[1].getAttribute('datetime') : start,
|
|
264
|
+
// <progress> is explicit about its scale, so convert to a fraction here.
|
|
265
|
+
progress: progressEl ? progressEl.value / (progressEl.max || 1) : 0,
|
|
266
|
+
group: row.dataset.group,
|
|
267
|
+
depends: row.dataset.depends,
|
|
268
|
+
status: row.dataset.status,
|
|
269
|
+
assignee: row.dataset.assignee,
|
|
275
270
|
milestone: row.hasAttribute('data-milestone'),
|
|
276
|
-
color: row.dataset.color
|
|
277
|
-
storyIds: row.dataset.storyIds
|
|
278
|
-
itemIds: row.dataset.itemIds
|
|
271
|
+
color: row.dataset.color,
|
|
272
|
+
storyIds: row.dataset.storyIds,
|
|
273
|
+
itemIds: row.dataset.itemIds,
|
|
279
274
|
});
|
|
280
275
|
}
|
|
276
|
+
return normalizeTasks(raw);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/* ── Render ────────────────────────────────────── */
|
|
280
|
+
|
|
281
|
+
#removeChrome() {
|
|
282
|
+
this.#titleEl?.remove(); this.#titleEl = null;
|
|
283
|
+
this.#container?.remove(); this.#container = null;
|
|
284
|
+
this.#liveRegion?.remove(); this.#liveRegion = null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The SR table: the author's for 'table', otherwise generated from #tasks. */
|
|
288
|
+
#syncTable() {
|
|
289
|
+
if (this.#source === 'table') {
|
|
290
|
+
return /** @type {HTMLTableElement|null} */ (this.querySelector(':scope > table'));
|
|
291
|
+
}
|
|
292
|
+
this.querySelectorAll(':scope > table').forEach((t) => t.remove());
|
|
293
|
+
if (!this.#tasks.length) return null;
|
|
294
|
+
const table = this.#buildTable();
|
|
295
|
+
this.appendChild(table);
|
|
296
|
+
return table;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#render() {
|
|
300
|
+
this.#removeChrome();
|
|
301
|
+
const table = this.#syncTable();
|
|
302
|
+
if (!this.#tasks.length) return;
|
|
281
303
|
|
|
282
|
-
|
|
304
|
+
const starts = this.#tasks.map((t) => t.start);
|
|
305
|
+
const ends = this.#tasks.map((t) => t.end);
|
|
306
|
+
this.#rangeStart = new Date(Math.min(...starts));
|
|
307
|
+
this.#rangeDays = Math.max(1, daysBetween(this.#rangeStart, new Date(Math.max(...ends))));
|
|
308
|
+
|
|
309
|
+
this.#build(table);
|
|
283
310
|
}
|
|
284
311
|
|
|
285
|
-
|
|
312
|
+
/** @param {number | Date} ts @returns {number} 0–100 */
|
|
313
|
+
#pct(ts) {
|
|
314
|
+
const d = typeof ts === 'number' ? new Date(ts) : ts;
|
|
315
|
+
return (daysBetween(this.#rangeStart, d) / this.#rangeDays) * 100;
|
|
316
|
+
}
|
|
286
317
|
|
|
318
|
+
/** `gantt-chart:ready`, at most once per element. */
|
|
319
|
+
#dispatchReady() {
|
|
320
|
+
if (this.#readyFired) return;
|
|
321
|
+
this.#readyFired = true;
|
|
322
|
+
this.dispatchEvent(new CustomEvent('gantt-chart:ready', {
|
|
323
|
+
bubbles: true,
|
|
324
|
+
detail: {
|
|
325
|
+
taskCount: this.#tasks.length,
|
|
326
|
+
dateRange: {
|
|
327
|
+
start: toISODate(this.#rangeStart),
|
|
328
|
+
end: toISODate(addDays(this.#rangeStart, this.#rangeDays)),
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
}));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** @param {HTMLTableElement | null} table */
|
|
287
335
|
#build(table) {
|
|
288
336
|
const title = this.getAttribute('title');
|
|
289
337
|
const showToday = this.hasAttribute('show-today');
|
|
290
338
|
const showProgress = this.hasAttribute('show-progress');
|
|
291
339
|
const showDeps = this.hasAttribute('show-dependencies');
|
|
340
|
+
const anchor = table && table.parentNode === this ? table : null;
|
|
292
341
|
|
|
293
|
-
// Title
|
|
294
342
|
if (title) {
|
|
295
343
|
this.#titleEl = document.createElement('h2');
|
|
296
344
|
this.#titleEl.className = 'gc-title';
|
|
297
345
|
this.#titleEl.textContent = title;
|
|
298
|
-
this.insertBefore(this.#titleEl,
|
|
346
|
+
this.insertBefore(this.#titleEl, anchor);
|
|
299
347
|
}
|
|
300
348
|
|
|
301
|
-
// Container
|
|
302
349
|
this.#container = document.createElement('div');
|
|
303
350
|
this.#container.className = 'gc-container';
|
|
304
351
|
this.#container.setAttribute('role', 'region');
|
|
305
352
|
this.#container.setAttribute('aria-label', `Gantt chart${title ? ': ' + title : ''}`);
|
|
306
353
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
this.#container.appendChild(header);
|
|
354
|
+
const labels = this.#generateDateLabels();
|
|
355
|
+
this.#container.appendChild(this.#buildTimelineHeader(labels));
|
|
310
356
|
|
|
311
|
-
// Body: task list + bars
|
|
312
357
|
const body = document.createElement('div');
|
|
313
358
|
body.className = 'gc-body';
|
|
314
359
|
|
|
@@ -317,16 +362,10 @@ class GanttChart extends VBElement {
|
|
|
317
362
|
|
|
318
363
|
const barsPanel = document.createElement('div');
|
|
319
364
|
barsPanel.className = 'gc-bars';
|
|
365
|
+
barsPanel.appendChild(this.#buildGridLines(labels));
|
|
320
366
|
|
|
321
|
-
// Grid lines
|
|
322
|
-
const gridLines = this.#buildGridLines();
|
|
323
|
-
barsPanel.appendChild(gridLines);
|
|
324
|
-
|
|
325
|
-
// Group tasks
|
|
326
367
|
const grouped = this.#groupTasks();
|
|
327
|
-
|
|
328
368
|
for (const [groupName, groupTasks] of grouped) {
|
|
329
|
-
// Group header (if there are multiple groups)
|
|
330
369
|
if (grouped.size > 1 && groupName) {
|
|
331
370
|
const groupHeader = document.createElement('div');
|
|
332
371
|
groupHeader.className = 'gc-group-header';
|
|
@@ -342,7 +381,6 @@ class GanttChart extends VBElement {
|
|
|
342
381
|
}
|
|
343
382
|
|
|
344
383
|
for (const task of groupTasks) {
|
|
345
|
-
// Task row (left)
|
|
346
384
|
const taskRow = document.createElement('div');
|
|
347
385
|
taskRow.className = 'gc-task-row';
|
|
348
386
|
taskRow.setAttribute('data-task-id', task.id);
|
|
@@ -352,54 +390,44 @@ class GanttChart extends VBElement {
|
|
|
352
390
|
taskName.textContent = task.name;
|
|
353
391
|
taskRow.appendChild(taskName);
|
|
354
392
|
|
|
355
|
-
//
|
|
393
|
+
// Status as visible text, so it is not conveyed by bar colour alone.
|
|
394
|
+
if (task.status) {
|
|
395
|
+
const status = document.createElement('span');
|
|
396
|
+
status.className = 'gc-task-status';
|
|
397
|
+
status.setAttribute('data-status', task.status);
|
|
398
|
+
status.textContent = statusLabel(task.status);
|
|
399
|
+
taskRow.appendChild(status);
|
|
400
|
+
}
|
|
401
|
+
|
|
356
402
|
const taskDates = document.createElement('span');
|
|
357
403
|
taskDates.className = 'gc-task-dates';
|
|
358
|
-
taskDates.textContent =
|
|
404
|
+
taskDates.textContent = task.milestone
|
|
405
|
+
? longDate(task.start)
|
|
406
|
+
: `${longDate(task.start)} to ${longDate(task.end)}`;
|
|
359
407
|
taskRow.appendChild(taskDates);
|
|
360
408
|
|
|
361
409
|
taskList.appendChild(taskRow);
|
|
362
410
|
|
|
363
|
-
// Bar row (right)
|
|
364
411
|
const barRow = document.createElement('div');
|
|
365
412
|
barRow.className = 'gc-bar-row';
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
barRow.appendChild(this.#buildMilestone(task));
|
|
369
|
-
} else {
|
|
370
|
-
barRow.appendChild(this.#buildBar(task, showProgress));
|
|
371
|
-
}
|
|
372
|
-
|
|
413
|
+
barRow.setAttribute('data-task-id', task.id);
|
|
414
|
+
barRow.appendChild(task.milestone ? this.#buildMilestone(task) : this.#buildBar(task, showProgress));
|
|
373
415
|
barsPanel.appendChild(barRow);
|
|
374
416
|
}
|
|
375
417
|
}
|
|
376
418
|
|
|
377
|
-
// Today line
|
|
378
419
|
if (showToday) {
|
|
379
420
|
const todayLine = this.#buildTodayLine();
|
|
380
421
|
if (todayLine) barsPanel.appendChild(todayLine);
|
|
381
422
|
}
|
|
382
423
|
|
|
383
|
-
// Dependency arrows
|
|
384
|
-
if (showDeps) {
|
|
385
|
-
// Defer to after DOM is in place for position calculation
|
|
386
|
-
requestAnimationFrame(() => {
|
|
387
|
-
const svg = this.#buildDependencyArrows(barsPanel);
|
|
388
|
-
if (svg) barsPanel.appendChild(svg);
|
|
389
|
-
});
|
|
390
|
-
}
|
|
391
|
-
|
|
392
424
|
body.appendChild(taskList);
|
|
393
425
|
body.appendChild(barsPanel);
|
|
394
426
|
this.#container.appendChild(body);
|
|
427
|
+
this.insertBefore(this.#container, anchor);
|
|
395
428
|
|
|
396
|
-
|
|
397
|
-
this.insertBefore(this.#container, table);
|
|
429
|
+
if (table) table.classList.add('gc-sr-only');
|
|
398
430
|
|
|
399
|
-
// Hide original table (keep for screen readers)
|
|
400
|
-
table.classList.add('gc-sr-only');
|
|
401
|
-
|
|
402
|
-
// Live region
|
|
403
431
|
this.#liveRegion = document.createElement('div');
|
|
404
432
|
this.#liveRegion.className = 'gc-sr-only';
|
|
405
433
|
this.#liveRegion.setAttribute('role', 'status');
|
|
@@ -407,49 +435,81 @@ class GanttChart extends VBElement {
|
|
|
407
435
|
this.#liveRegion.setAttribute('aria-atomic', 'true');
|
|
408
436
|
this.appendChild(this.#liveRegion);
|
|
409
437
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
this.dispatchEvent(new CustomEvent('gantt-chart:task-click', {
|
|
420
|
-
bubbles: true,
|
|
421
|
-
detail: { task },
|
|
422
|
-
}));
|
|
423
|
-
});
|
|
438
|
+
if (showDeps) {
|
|
439
|
+
// Measure after layout: row heights depend on compact/group spacers.
|
|
440
|
+
requestAnimationFrame(() => {
|
|
441
|
+
if (!barsPanel.isConnected) return;
|
|
442
|
+
const svg = this.#buildDependencyArrows(barsPanel);
|
|
443
|
+
if (svg) barsPanel.appendChild(svg);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
424
447
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
448
|
+
/* ── Generated SR table (property / src data) ──── */
|
|
449
|
+
|
|
450
|
+
#buildTable() {
|
|
451
|
+
const table = document.createElement('table');
|
|
452
|
+
table.setAttribute('data-gc-generated', '');
|
|
453
|
+
const thead = document.createElement('thead');
|
|
454
|
+
const headerRow = document.createElement('tr');
|
|
455
|
+
for (const label of ['Task', 'Start', 'End', 'Progress', 'Status']) {
|
|
456
|
+
const th = document.createElement('th');
|
|
457
|
+
th.scope = 'col';
|
|
458
|
+
th.textContent = label;
|
|
459
|
+
headerRow.appendChild(th);
|
|
460
|
+
}
|
|
461
|
+
thead.appendChild(headerRow);
|
|
462
|
+
table.appendChild(thead);
|
|
463
|
+
|
|
464
|
+
const tbody = document.createElement('tbody');
|
|
465
|
+
for (const task of this.#tasks) {
|
|
466
|
+
const tr = document.createElement('tr');
|
|
467
|
+
tr.dataset.taskId = task.id;
|
|
468
|
+
const tdName = document.createElement('td');
|
|
469
|
+
tdName.textContent = task.name;
|
|
470
|
+
tr.appendChild(tdName);
|
|
471
|
+
|
|
472
|
+
for (const ts of [task.start, task.end]) {
|
|
473
|
+
const td = document.createElement('td');
|
|
474
|
+
const time = document.createElement('time');
|
|
475
|
+
time.setAttribute('datetime', toISODate(new Date(ts)));
|
|
476
|
+
time.textContent = longDate(ts);
|
|
477
|
+
td.appendChild(time);
|
|
478
|
+
tr.appendChild(td);
|
|
433
479
|
}
|
|
434
|
-
|
|
480
|
+
|
|
481
|
+
const tdProgress = document.createElement('td');
|
|
482
|
+
const progress = document.createElement('progress');
|
|
483
|
+
const pct = Math.round(task.progress * 100);
|
|
484
|
+
progress.max = 100;
|
|
485
|
+
progress.value = pct;
|
|
486
|
+
progress.textContent = `${pct}%`;
|
|
487
|
+
tdProgress.appendChild(progress);
|
|
488
|
+
tr.appendChild(tdProgress);
|
|
489
|
+
|
|
490
|
+
const tdStatus = document.createElement('td');
|
|
491
|
+
tdStatus.textContent = task.status ? statusLabel(task.status) : '';
|
|
492
|
+
tr.appendChild(tdStatus);
|
|
493
|
+
|
|
494
|
+
tbody.appendChild(tr);
|
|
495
|
+
}
|
|
496
|
+
table.appendChild(tbody);
|
|
497
|
+
return table;
|
|
435
498
|
}
|
|
436
499
|
|
|
437
500
|
/* ── Timeline header ───────────────────────────── */
|
|
438
501
|
|
|
439
|
-
|
|
502
|
+
/** @param {Array<{position: number, text: string}>} labels */
|
|
503
|
+
#buildTimelineHeader(labels) {
|
|
440
504
|
const header = document.createElement('div');
|
|
441
505
|
header.className = 'gc-timeline-header';
|
|
442
506
|
|
|
443
|
-
// Spacer matching task list width
|
|
444
507
|
const spacer = document.createElement('div');
|
|
445
508
|
spacer.className = 'gc-timeline-spacer';
|
|
446
509
|
header.appendChild(spacer);
|
|
447
510
|
|
|
448
|
-
// Date labels
|
|
449
511
|
const dates = document.createElement('div');
|
|
450
512
|
dates.className = 'gc-timeline-dates';
|
|
451
|
-
|
|
452
|
-
const labels = this.#generateDateLabels();
|
|
453
513
|
for (const { position, text } of labels) {
|
|
454
514
|
const label = document.createElement('span');
|
|
455
515
|
label.className = 'gc-date-label';
|
|
@@ -466,81 +526,52 @@ class GanttChart extends VBElement {
|
|
|
466
526
|
|
|
467
527
|
#generateDateLabels() {
|
|
468
528
|
const viewAttr = this.getAttribute('view') || 'auto';
|
|
469
|
-
const view = viewAttr
|
|
470
|
-
const formatter = pickFormatter(this.#
|
|
529
|
+
const view = STEP_DAYS[viewAttr] ? viewAttr : resolveView(this.#rangeDays);
|
|
530
|
+
const formatter = pickFormatter(this.#rangeDays);
|
|
531
|
+
const rangeStart = this.#rangeStart;
|
|
532
|
+
const rangeEnd = addDays(rangeStart, this.#rangeDays);
|
|
471
533
|
const labels = [];
|
|
472
534
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
case 'week': stepMs = WEEK; break;
|
|
477
|
-
case 'month': stepMs = MONTH; break;
|
|
478
|
-
case 'quarter': stepMs = QUARTER; break;
|
|
479
|
-
default: stepMs = MONTH;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// Align to calendar boundaries
|
|
483
|
-
let cursor = new Date(this.#rangeStart);
|
|
535
|
+
/** @type {(d: Date) => Date} */
|
|
536
|
+
let step;
|
|
537
|
+
let cursor;
|
|
484
538
|
if (view === 'week') {
|
|
485
|
-
//
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
539
|
+
// First Monday on or after the range start.
|
|
540
|
+
const dow = rangeStart.getDay();
|
|
541
|
+
cursor = addDays(rangeStart, (8 - dow) % 7);
|
|
542
|
+
step = (d) => addDays(d, 7);
|
|
489
543
|
} else if (view === 'month') {
|
|
490
|
-
cursor
|
|
491
|
-
if (cursor
|
|
492
|
-
|
|
493
|
-
}
|
|
544
|
+
cursor = startOfMonth(rangeStart);
|
|
545
|
+
if (cursor < rangeStart) cursor = addMonths(cursor, 1);
|
|
546
|
+
step = (d) => addMonths(d, 1);
|
|
494
547
|
} else if (view === 'quarter') {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
cursor
|
|
498
|
-
|
|
548
|
+
// Day 1 is set before the month moves, so a Mar 31 start aligns to Apr 1.
|
|
549
|
+
cursor = startOfQuarter(rangeStart);
|
|
550
|
+
if (cursor < rangeStart) cursor = addMonths(cursor, 3);
|
|
551
|
+
step = (d) => addMonths(d, 3);
|
|
552
|
+
} else {
|
|
553
|
+
cursor = startOfDay(rangeStart);
|
|
554
|
+
step = (d) => addDays(d, 1);
|
|
499
555
|
}
|
|
500
556
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
labels.push({
|
|
505
|
-
position: 0,
|
|
506
|
-
text: formatter(new Date(this.#rangeStart)),
|
|
507
|
-
});
|
|
557
|
+
// Label the range start if the first aligned tick is well past it.
|
|
558
|
+
if (daysBetween(rangeStart, cursor) > STEP_DAYS[view] * 0.5) {
|
|
559
|
+
labels.push({ position: 0, text: formatter(rangeStart) });
|
|
508
560
|
}
|
|
509
561
|
|
|
510
|
-
let
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
if (pct >= 0 && pct <= 100) {
|
|
514
|
-
labels.push({
|
|
515
|
-
position: pct,
|
|
516
|
-
text: formatter(new Date(current)),
|
|
517
|
-
});
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
// Step forward
|
|
521
|
-
if (view === 'month') {
|
|
522
|
-
const d = new Date(current);
|
|
523
|
-
d.setMonth(d.getMonth() + 1);
|
|
524
|
-
current = d.getTime();
|
|
525
|
-
} else if (view === 'quarter') {
|
|
526
|
-
const d = new Date(current);
|
|
527
|
-
d.setMonth(d.getMonth() + 3);
|
|
528
|
-
current = d.getTime();
|
|
529
|
-
} else {
|
|
530
|
-
current += stepMs;
|
|
531
|
-
}
|
|
562
|
+
for (let d = cursor; d <= rangeEnd; d = step(d)) {
|
|
563
|
+
const pct = this.#pct(d);
|
|
564
|
+
if (pct >= 0 && pct <= 100) labels.push({ position: pct, text: formatter(d) });
|
|
532
565
|
}
|
|
533
|
-
|
|
534
566
|
return labels;
|
|
535
567
|
}
|
|
536
568
|
|
|
537
569
|
/* ── Grid lines ────────────────────────────────── */
|
|
538
570
|
|
|
539
|
-
|
|
571
|
+
/** @param {Array<{position: number}>} labels */
|
|
572
|
+
#buildGridLines(labels) {
|
|
540
573
|
const container = document.createElement('div');
|
|
541
574
|
container.className = 'gc-grid-lines';
|
|
542
|
-
|
|
543
|
-
const labels = this.#generateDateLabels();
|
|
544
575
|
for (const { position } of labels) {
|
|
545
576
|
if (position <= 0) continue;
|
|
546
577
|
const line = document.createElement('div');
|
|
@@ -548,40 +579,48 @@ class GanttChart extends VBElement {
|
|
|
548
579
|
line.style.left = `${position}%`;
|
|
549
580
|
container.appendChild(line);
|
|
550
581
|
}
|
|
551
|
-
|
|
552
582
|
return container;
|
|
553
583
|
}
|
|
554
584
|
|
|
555
585
|
/* ── Group tasks ───────────────────────────────── */
|
|
556
586
|
|
|
557
587
|
#groupTasks() {
|
|
558
|
-
/** @type {Map<string
|
|
588
|
+
/** @type {Map<string, any[]>} */
|
|
559
589
|
const groups = new Map();
|
|
560
|
-
|
|
561
590
|
for (const task of this.#tasks) {
|
|
562
591
|
const key = task.group || '';
|
|
563
592
|
if (!groups.has(key)) groups.set(key, []);
|
|
564
593
|
groups.get(key)?.push(task);
|
|
565
594
|
}
|
|
566
|
-
|
|
567
595
|
return groups;
|
|
568
596
|
}
|
|
569
597
|
|
|
598
|
+
/* ── Accessible name ───────────────────────────── */
|
|
599
|
+
|
|
600
|
+
/** @param {any} task */
|
|
601
|
+
#taskLabel(task) {
|
|
602
|
+
const parts = [task.milestone
|
|
603
|
+
? `Milestone: ${task.name}, ${longDate(task.start)}`
|
|
604
|
+
: `${task.name}: ${longDate(task.start)} to ${longDate(task.end)}`];
|
|
605
|
+
if (task.status) parts.push(`status: ${statusLabel(task.status).toLowerCase()}`);
|
|
606
|
+
if (!task.milestone && task.progress > 0) parts.push(`${Math.round(task.progress * 100)}% complete`);
|
|
607
|
+
if (task.assignee) parts.push(`assigned to ${task.assignee}`);
|
|
608
|
+
return parts.join(', ');
|
|
609
|
+
}
|
|
610
|
+
|
|
570
611
|
/* ── Build bar ─────────────────────────────────── */
|
|
571
612
|
|
|
613
|
+
/** @param {any} task @param {boolean} showProgress */
|
|
572
614
|
#buildBar(task, showProgress) {
|
|
573
|
-
const leftPct = (
|
|
574
|
-
const widthPct = (
|
|
615
|
+
const leftPct = this.#pct(task.start);
|
|
616
|
+
const widthPct = this.#pct(task.end) - leftPct;
|
|
575
617
|
|
|
576
618
|
const bar = document.createElement('div');
|
|
577
619
|
bar.className = 'gc-bar';
|
|
578
620
|
bar.setAttribute('data-task-id', task.id);
|
|
579
|
-
bar.setAttribute('role', '
|
|
621
|
+
bar.setAttribute('role', 'button');
|
|
580
622
|
bar.setAttribute('tabindex', '0');
|
|
581
|
-
bar.setAttribute('aria-label',
|
|
582
|
-
`${esc(task.name)}: ${new Date(task.start).toLocaleDateString()} to ${new Date(task.end).toLocaleDateString()}` +
|
|
583
|
-
(task.progress > 0 ? `, ${Math.round(task.progress * 100)}% complete` : '')
|
|
584
|
-
);
|
|
623
|
+
bar.setAttribute('aria-label', this.#taskLabel(task));
|
|
585
624
|
|
|
586
625
|
bar.style.left = `${leftPct}%`;
|
|
587
626
|
bar.style.width = `${Math.max(widthPct, 0.5)}%`;
|
|
@@ -589,7 +628,6 @@ class GanttChart extends VBElement {
|
|
|
589
628
|
if (task.status) bar.setAttribute('data-status', task.status);
|
|
590
629
|
if (task.color) bar.style.setProperty('--gc-bar-color', task.color);
|
|
591
630
|
|
|
592
|
-
// Progress fill
|
|
593
631
|
if (showProgress && task.progress > 0) {
|
|
594
632
|
const fill = document.createElement('div');
|
|
595
633
|
fill.className = 'gc-bar-fill';
|
|
@@ -597,10 +635,10 @@ class GanttChart extends VBElement {
|
|
|
597
635
|
bar.appendChild(fill);
|
|
598
636
|
}
|
|
599
637
|
|
|
600
|
-
// Label (show name if bar is wide enough)
|
|
601
638
|
if (widthPct > 8) {
|
|
602
639
|
const label = document.createElement('span');
|
|
603
640
|
label.className = 'gc-bar-label';
|
|
641
|
+
label.setAttribute('aria-hidden', 'true');
|
|
604
642
|
label.textContent = task.name;
|
|
605
643
|
bar.appendChild(label);
|
|
606
644
|
}
|
|
@@ -610,16 +648,15 @@ class GanttChart extends VBElement {
|
|
|
610
648
|
|
|
611
649
|
/* ── Build milestone ───────────────────────────── */
|
|
612
650
|
|
|
651
|
+
/** @param {any} task */
|
|
613
652
|
#buildMilestone(task) {
|
|
614
|
-
const leftPct = ((task.start - this.#rangeStart) / this.#rangeTotal) * 100;
|
|
615
|
-
|
|
616
653
|
const diamond = document.createElement('div');
|
|
617
654
|
diamond.className = 'gc-milestone';
|
|
618
655
|
diamond.setAttribute('data-task-id', task.id);
|
|
619
|
-
diamond.setAttribute('role', '
|
|
656
|
+
diamond.setAttribute('role', 'button');
|
|
620
657
|
diamond.setAttribute('tabindex', '0');
|
|
621
|
-
diamond.setAttribute('aria-label',
|
|
622
|
-
diamond.style.left = `${
|
|
658
|
+
diamond.setAttribute('aria-label', this.#taskLabel(task));
|
|
659
|
+
diamond.style.left = `${this.#pct(task.start)}%`;
|
|
623
660
|
|
|
624
661
|
if (task.status) diamond.setAttribute('data-status', task.status);
|
|
625
662
|
if (task.color) diamond.style.setProperty('--gc-bar-color', task.color);
|
|
@@ -630,14 +667,14 @@ class GanttChart extends VBElement {
|
|
|
630
667
|
/* ── Today line ────────────────────────────────── */
|
|
631
668
|
|
|
632
669
|
#buildTodayLine() {
|
|
633
|
-
const now = Date
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
670
|
+
const now = new Date();
|
|
671
|
+
const dayFraction = (now.getTime() - startOfDay(now).getTime()) / 86_400_000;
|
|
672
|
+
const days = daysBetween(this.#rangeStart, now) + dayFraction;
|
|
673
|
+
if (days < 0 || days > this.#rangeDays) return null;
|
|
637
674
|
|
|
638
675
|
const line = document.createElement('div');
|
|
639
676
|
line.className = 'gc-today-line';
|
|
640
|
-
line.style.left = `${
|
|
677
|
+
line.style.left = `${(days / this.#rangeDays) * 100}%`;
|
|
641
678
|
|
|
642
679
|
const label = document.createElement('span');
|
|
643
680
|
label.className = 'gc-today-label';
|
|
@@ -649,28 +686,38 @@ class GanttChart extends VBElement {
|
|
|
649
686
|
|
|
650
687
|
/* ── Dependency arrows ─────────────────────────── */
|
|
651
688
|
|
|
689
|
+
/** @param {HTMLElement} barsPanel */
|
|
652
690
|
#buildDependencyArrows(barsPanel) {
|
|
653
|
-
|
|
654
|
-
|
|
691
|
+
if (!this.#tasks.some((t) => t.depends.length > 0)) return null;
|
|
692
|
+
|
|
693
|
+
/* Row centres are measured, not computed from a fixed row height, so
|
|
694
|
+
compact mode and group spacers line up. */
|
|
695
|
+
/** @type {Map<string, number>} */
|
|
696
|
+
const centreY = new Map();
|
|
697
|
+
for (const row of /** @type {NodeListOf<HTMLElement>} */ (barsPanel.querySelectorAll(':scope > .gc-bar-row'))) {
|
|
698
|
+
centreY.set(row.dataset.taskId || '', row.offsetTop + row.offsetHeight / 2);
|
|
699
|
+
}
|
|
700
|
+
const height = Math.max(1, Math.round(barsPanel.scrollHeight));
|
|
655
701
|
|
|
656
702
|
const ns = 'http://www.w3.org/2000/svg';
|
|
657
|
-
const rowHeight = 36; // default --gc-row-height
|
|
658
|
-
const rowCount = barsPanel.querySelectorAll('.gc-bar-row').length;
|
|
659
703
|
const svg = document.createElementNS(ns, 'svg');
|
|
660
704
|
svg.setAttribute('class', 'gc-deps');
|
|
705
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
661
706
|
// x is a percentage (0–100, matching the bars' CSS `left: N%`) and y is in
|
|
662
707
|
// pixels. A non-uniform viewBox maps both, so the path `d` can use plain
|
|
663
708
|
// numbers — SVG path data does not accept `%` units. non-scaling-stroke
|
|
664
|
-
//
|
|
665
|
-
svg.setAttribute('viewBox', `0 0 100 ${
|
|
709
|
+
// keeps the line/arrowhead uniform despite the x-axis stretch.
|
|
710
|
+
svg.setAttribute('viewBox', `0 0 100 ${height}`);
|
|
666
711
|
svg.setAttribute('preserveAspectRatio', 'none');
|
|
667
712
|
svg.style.width = '100%';
|
|
668
|
-
svg.style.height =
|
|
713
|
+
svg.style.height = `${height}px`;
|
|
669
714
|
|
|
670
|
-
//
|
|
715
|
+
// Per-instance marker id: url(#id) resolves document-wide, so a shared
|
|
716
|
+
// id would point every chart at the first chart's marker.
|
|
717
|
+
const markerId = `${this.#uid}-arrowhead`;
|
|
671
718
|
const defs = document.createElementNS(ns, 'defs');
|
|
672
719
|
const marker = document.createElementNS(ns, 'marker');
|
|
673
|
-
marker.setAttribute('id',
|
|
720
|
+
marker.setAttribute('id', markerId);
|
|
674
721
|
marker.setAttribute('markerWidth', '8');
|
|
675
722
|
marker.setAttribute('markerHeight', '6');
|
|
676
723
|
marker.setAttribute('refX', '8');
|
|
@@ -685,24 +732,22 @@ class GanttChart extends VBElement {
|
|
|
685
732
|
|
|
686
733
|
for (const task of this.#tasks) {
|
|
687
734
|
for (const depId of task.depends) {
|
|
688
|
-
const source = this.#tasks.find(t => t.id === depId);
|
|
735
|
+
const source = this.#tasks.find((t) => t.id === depId);
|
|
689
736
|
if (!source) continue;
|
|
737
|
+
const sourceY = centreY.get(source.id);
|
|
738
|
+
const targetY = centreY.get(task.id);
|
|
739
|
+
if (sourceY === undefined || targetY === undefined) continue;
|
|
690
740
|
|
|
691
|
-
const sourceRight = (
|
|
692
|
-
const targetLeft = (
|
|
693
|
-
|
|
694
|
-
// Find row indices for vertical positioning
|
|
695
|
-
const sourceIdx = this.#getVisualRowIndex(source.id, barsPanel);
|
|
696
|
-
const targetIdx = this.#getVisualRowIndex(task.id, barsPanel);
|
|
697
|
-
if (sourceIdx === -1 || targetIdx === -1) continue;
|
|
698
|
-
|
|
699
|
-
const sourceY = sourceIdx * rowHeight + rowHeight / 2;
|
|
700
|
-
const targetY = targetIdx * rowHeight + rowHeight / 2;
|
|
741
|
+
const sourceRight = this.#pct(source.end);
|
|
742
|
+
const targetLeft = this.#pct(task.start);
|
|
743
|
+
const midX = sourceRight + (targetLeft - sourceRight) / 2;
|
|
701
744
|
|
|
702
745
|
const path = document.createElementNS(ns, 'path');
|
|
703
|
-
const midX = sourceRight + (targetLeft - sourceRight) / 2;
|
|
704
746
|
path.setAttribute('d', `M ${sourceRight} ${sourceY} C ${midX} ${sourceY}, ${midX} ${targetY}, ${targetLeft} ${targetY}`);
|
|
705
747
|
path.setAttribute('class', 'gc-dep-line');
|
|
748
|
+
path.setAttribute('data-from', source.id);
|
|
749
|
+
path.setAttribute('data-to', task.id);
|
|
750
|
+
path.setAttribute('marker-end', `url(#${markerId})`);
|
|
706
751
|
path.setAttribute('vector-effect', 'non-scaling-stroke');
|
|
707
752
|
svg.appendChild(path);
|
|
708
753
|
}
|
|
@@ -711,94 +756,74 @@ class GanttChart extends VBElement {
|
|
|
711
756
|
return svg;
|
|
712
757
|
}
|
|
713
758
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
}
|
|
720
|
-
return
|
|
759
|
+
/* ── Interaction (delegated once in setup) ─────── */
|
|
760
|
+
|
|
761
|
+
/** @param {Event} e */
|
|
762
|
+
#barFromEvent(e) {
|
|
763
|
+
const t = /** @type {Element} */ (e.target);
|
|
764
|
+
const bar = /** @type {HTMLElement | null} */ (t?.closest?.('.gc-bar, .gc-milestone'));
|
|
765
|
+
return bar && this.#container?.contains(bar) ? bar : null;
|
|
721
766
|
}
|
|
722
767
|
|
|
723
|
-
|
|
768
|
+
#onClick = (e) => {
|
|
769
|
+
const bar = this.#barFromEvent(e);
|
|
770
|
+
if (!bar) return;
|
|
771
|
+
const task = this.#tasks.find((t) => t.id === bar.dataset.taskId);
|
|
772
|
+
if (!task) return;
|
|
773
|
+
this.#announce(`Selected: ${task.name}`);
|
|
774
|
+
this.dispatchEvent(new CustomEvent('gantt-chart:task-click', {
|
|
775
|
+
bubbles: true,
|
|
776
|
+
detail: { task },
|
|
777
|
+
}));
|
|
778
|
+
};
|
|
724
779
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
while (this.firstChild) this.firstChild.remove();
|
|
734
|
-
|
|
735
|
-
// Build a table from JSON
|
|
736
|
-
const table = document.createElement('table');
|
|
737
|
-
const thead = document.createElement('thead');
|
|
738
|
-
const headerRow = document.createElement('tr');
|
|
739
|
-
for (const label of ['Task', 'Start', 'End', 'Progress']) {
|
|
740
|
-
const th = document.createElement('th');
|
|
741
|
-
th.textContent = label;
|
|
742
|
-
headerRow.appendChild(th);
|
|
743
|
-
}
|
|
744
|
-
thead.appendChild(headerRow);
|
|
745
|
-
table.appendChild(thead);
|
|
746
|
-
|
|
747
|
-
const tbody = document.createElement('tbody');
|
|
748
|
-
for (const task of (data.tasks || [])) {
|
|
749
|
-
const tr = document.createElement('tr');
|
|
750
|
-
if (task.id) tr.dataset.taskId = task.id;
|
|
751
|
-
if (task.group) tr.dataset.group = task.group;
|
|
752
|
-
if (task.depends) tr.dataset.depends = Array.isArray(task.depends) ? task.depends.join(',') : task.depends;
|
|
753
|
-
if (task.status) tr.dataset.status = task.status;
|
|
754
|
-
if (task.assignee) tr.dataset.assignee = task.assignee;
|
|
755
|
-
if (task.milestone) tr.setAttribute('data-milestone', '');
|
|
756
|
-
if (task.color) tr.dataset.color = task.color;
|
|
757
|
-
if (task.storyIds) tr.dataset.storyIds = Array.isArray(task.storyIds) ? task.storyIds.join(',') : task.storyIds;
|
|
758
|
-
if (task.itemIds) tr.dataset.itemIds = Array.isArray(task.itemIds) ? task.itemIds.join(',') : task.itemIds;
|
|
759
|
-
|
|
760
|
-
const tdName = document.createElement('td');
|
|
761
|
-
tdName.textContent = task.name || '';
|
|
762
|
-
tr.appendChild(tdName);
|
|
763
|
-
|
|
764
|
-
const tdStart = document.createElement('td');
|
|
765
|
-
const timeStart = document.createElement('time');
|
|
766
|
-
timeStart.setAttribute('datetime', task.start);
|
|
767
|
-
timeStart.textContent = new Date(task.start).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
768
|
-
tdStart.appendChild(timeStart);
|
|
769
|
-
tr.appendChild(tdStart);
|
|
770
|
-
|
|
771
|
-
const tdEnd = document.createElement('td');
|
|
772
|
-
const timeEnd = document.createElement('time');
|
|
773
|
-
timeEnd.setAttribute('datetime', task.end || task.start);
|
|
774
|
-
timeEnd.textContent = new Date(task.end || task.start).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
775
|
-
tdEnd.appendChild(timeEnd);
|
|
776
|
-
tr.appendChild(tdEnd);
|
|
777
|
-
|
|
778
|
-
const tdProgress = document.createElement('td');
|
|
779
|
-
const progress = document.createElement('progress');
|
|
780
|
-
progress.value = task.progress || 0;
|
|
781
|
-
progress.max = 100;
|
|
782
|
-
progress.textContent = `${task.progress || 0}%`;
|
|
783
|
-
tdProgress.appendChild(progress);
|
|
784
|
-
tr.appendChild(tdProgress);
|
|
785
|
-
|
|
786
|
-
tbody.appendChild(tr);
|
|
787
|
-
}
|
|
788
|
-
table.appendChild(tbody);
|
|
789
|
-
this.appendChild(table);
|
|
780
|
+
#onKeyDown = (e) => {
|
|
781
|
+
const ke = /** @type {KeyboardEvent} */ (e);
|
|
782
|
+
if (ke.key !== 'Enter' && ke.key !== ' ') return;
|
|
783
|
+
const bar = this.#barFromEvent(ke);
|
|
784
|
+
if (!bar) return;
|
|
785
|
+
ke.preventDefault();
|
|
786
|
+
bar.click();
|
|
787
|
+
};
|
|
790
788
|
|
|
791
|
-
|
|
792
|
-
if (data.title) this.setAttribute('title', data.title);
|
|
789
|
+
/* ── JSON loading ──────────────────────────────── */
|
|
793
790
|
|
|
794
|
-
|
|
791
|
+
/**
|
|
792
|
+
* Fetch src JSON and replace the tasks. A newer src, a `.tasks`
|
|
793
|
+
* assignment or removing src aborts this request (SrcLoader).
|
|
794
|
+
* @param {string} url
|
|
795
|
+
*/
|
|
796
|
+
async #loadSrc(url) {
|
|
797
|
+
let result;
|
|
798
|
+
try {
|
|
799
|
+
result = await this.#loader.fetchJson(url);
|
|
795
800
|
} catch (err) {
|
|
796
801
|
console.warn(`[gantt-chart] Failed to load src="${url}":`, err);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
if (!result) return; // superseded, cancelled or aborted
|
|
805
|
+
const { data } = result;
|
|
806
|
+
this.#tasks = normalizeTasks(data?.tasks || []);
|
|
807
|
+
this.#source = 'src';
|
|
808
|
+
if (data?.title && data.title !== this.getAttribute('title')) {
|
|
809
|
+
this.#internalAttr = true;
|
|
810
|
+
this.setAttribute('title', data.title);
|
|
811
|
+
this.#internalAttr = false;
|
|
797
812
|
}
|
|
813
|
+
// Rendered synchronously so listeners of the events below see the new DOM.
|
|
814
|
+
if (this.hasAttribute('data-upgraded')) {
|
|
815
|
+
this.#render();
|
|
816
|
+
this.#dispatchReady();
|
|
817
|
+
}
|
|
818
|
+
this.dispatchEvent(new CustomEvent('gantt-chart:tasks-changed', {
|
|
819
|
+
detail: { tasks: this.#tasks, source: 'src' },
|
|
820
|
+
bubbles: true,
|
|
821
|
+
}));
|
|
798
822
|
}
|
|
799
823
|
|
|
800
824
|
/* ── Live region ───────────────────────────────── */
|
|
801
825
|
|
|
826
|
+
/** @param {string} msg */
|
|
802
827
|
#announce(msg) {
|
|
803
828
|
if (!this.#liveRegion) return;
|
|
804
829
|
this.#liveRegion.textContent = '';
|