@fatcore/gantt-lite 1.0.1 → 1.0.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.
@@ -1,323 +0,0 @@
1
- /* GANTT ROOT */
2
- :host {
3
- display: block;
4
- width: 100%;
5
- height: 100%;
6
- }
7
-
8
- .gantt-root {
9
- height: 100%;
10
- width: 100%;
11
- display: flex;
12
- flex-direction: column;
13
- overflow: hidden;
14
- }
15
-
16
- .gantt-body {
17
- flex: 1;
18
- display: flex;
19
- overflow-y: auto;
20
- overflow-x: hidden;
21
- border-width: 1px;
22
- border-style: solid;
23
- border-color: rgba(170, 170, 170, 0.8);
24
- }
25
-
26
- /* TOOLBAR */
27
- .toolbar {
28
- flex-shrink: 0;
29
- display: flex;
30
- gap: 8px;
31
- background: white;
32
- position: sticky;
33
- top: 0;
34
- z-index: 50;
35
- }
36
-
37
- .toolbar button.active {
38
- font-weight: bold;
39
- }
40
-
41
- /* LOADING */
42
- .loading-overlay {
43
- position: sticky;
44
- inset: 0;
45
- display: flex;
46
- align-items: center;
47
- justify-content: center;
48
- z-index: 1000;
49
- }
50
-
51
- /* TOOLTIP */
52
- .global-tooltip {
53
- position: fixed;
54
- background: rgba(88, 88, 88, 0.9);
55
- color: white;
56
- padding: 8px;
57
- border-radius: 6px;
58
- font-size: 12px;
59
- z-index: 1000;
60
- pointer-events: none;
61
- }
62
-
63
- /* LEFT PANEL */
64
- .left-panel {
65
- flex-shrink: 0;
66
- background: white;
67
- border-right: 1px solid #dcdcdc;
68
- }
69
-
70
- .task-row {
71
- display: grid;
72
- grid-auto-flow: column;
73
- grid-auto-columns: 120px;
74
- gap: 0 10px;
75
- font-size: small;
76
- align-items: center;
77
- border-bottom: 1px solid #eee;
78
- white-space: nowrap;
79
- overflow: hidden;
80
- }
81
-
82
- .cell {
83
- padding: 0 8px;
84
- overflow: hidden;
85
- white-space: nowrap;
86
- text-overflow: ellipsis;
87
- }
88
-
89
- .task-row.header {
90
- font-size: small;
91
- font-weight: 600;
92
- position: sticky;
93
- top: 0;
94
- background: white !important;
95
- z-index: 1;
96
- }
97
-
98
- .task-row:hover {
99
- background: rgba(255, 0, 0, 0.1);
100
- }
101
-
102
- .highlighted-row {
103
- background: rgba(255, 0, 0, 0.3) !important;
104
- }
105
-
106
- /* RESIZER */
107
- .resizer {
108
- min-width: 7px;
109
- cursor: col-resize;
110
- background: #b4b4b4;
111
- }
112
-
113
- .resizer:hover {
114
- background: #696969;
115
- }
116
-
117
-
118
- /* RIGHT PANEL */
119
- .right-panel {
120
- flex: 1;
121
- min-width: 100px !important;
122
- position: relative;
123
- }
124
-
125
- .right-panel-dead {
126
- flex: 1;
127
- height: 10000px;
128
- min-width: 100px !important;
129
- position: relative;
130
- background-color: #696969;
131
- }
132
-
133
- /* ONLY HORIZONTAL SCROLL */
134
- .horizontal-scroll {
135
- overflow-x: hidden;
136
- overflow-y: hidden;
137
- position: relative;
138
- }
139
-
140
- /* MAIN CONTENT */
141
- .gantt-content {
142
- position: relative;
143
- }
144
-
145
- /* TIMELINE HEADER */
146
- .timeline-header {
147
- display: flex;
148
- position: sticky;
149
- top: 0;
150
- overflow: hidden;
151
- white-space: nowrap;
152
- z-index: 20;
153
- background: white;
154
- border-bottom: 1px solid #ccc;
155
- }
156
-
157
- .timeline-slot {
158
- flex: 0 0 auto;
159
- border-right: 1px solid #e0e0e0;
160
- padding: 8px;
161
- box-sizing: border-box;
162
- text-align: center;
163
- font-size: 12px;
164
- }
165
-
166
- .highlighted-task {
167
- background: rgba(255, 0, 0, 0.4) !important;
168
- border-color: #b71c1c !important;
169
- z-index: 10;
170
- }
171
-
172
- /* GRID */
173
- .grid-layer {
174
- position: absolute;
175
- inset: 0;
176
- pointer-events: none;
177
- }
178
-
179
- .grid-line {
180
- position: absolute;
181
- top: 0;
182
- bottom: 0;
183
- width: 1px;
184
- background: rgba(200, 200, 200, 0.35);
185
- }
186
-
187
- .horizontal-grid-layer {
188
- position: absolute;
189
- inset: 0;
190
- pointer-events: none;
191
- }
192
-
193
- .horizontal-line {
194
- position: absolute;
195
- left: 0;
196
- right: 0;
197
- height: 1px;
198
- background: rgba(200, 200, 200, 0.25);
199
- }
200
-
201
- /* DEPENDENCIES */
202
- .dependencies-svg {
203
- overflow: visible;
204
- }
205
-
206
- .dependencies-normal {
207
- pointer-events: none;
208
- }
209
-
210
- .dependencies-highlighted {
211
- pointer-events: none;
212
- }
213
-
214
- /* NORMAL DEPENDENCIES */
215
- .dependency {
216
- stroke: #9ba2ad;
217
- stroke-width: 1.6;
218
- fill: none;
219
- opacity: 0.35;
220
- }
221
-
222
- /* HIGHLIGHTED DEPENDENCIES */
223
- .highlighted-dependency {
224
- stroke: #52555a;
225
- stroke-width: 2.7;
226
- fill: none;
227
- opacity: 1;
228
- }
229
-
230
- /* OPTIONAL: fade non-selected dependencies when hovering */
231
- .dependencies-svg.hovering .dependency {
232
- opacity: 0.035;
233
- }
234
-
235
- /* TASKS */
236
- .tasks-layer {
237
- position: relative;
238
- z-index: 2;
239
- }
240
-
241
- .task {
242
- position: absolute;
243
- background: rgba(155, 148, 247, 0.75);
244
- opacity: 0.85;
245
- color: rgb(80, 80, 80);
246
- padding-top: 4px;
247
- border-radius: 4px;
248
- font-size: 11px;
249
- white-space: nowrap;
250
- min-width: 2px;
251
- }
252
-
253
- .task:hover {
254
- background: rgba(255, 0, 0, 0.15);
255
- }
256
-
257
- /* SPINNER */
258
- .spinner {
259
- width: 40px;
260
- height: 40px;
261
- border: 4px solid #ddd;
262
- border-top-color: #3f51b5;
263
- border-radius: 50%;
264
- animation: spin 1s linear infinite;
265
- }
266
-
267
- @keyframes spin {
268
- to {
269
- transform: rotate(360deg);
270
- }
271
- }
272
-
273
- * {
274
- box-sizing: border-box;
275
- }
276
-
277
- /* BUTTON SCROLL GANTT */
278
- .floating-nav {
279
- position: sticky;
280
- top: 50%;
281
- transform: translateY(-50%);
282
- z-index: 100;
283
- pointer-events: none;
284
- }
285
-
286
- .nav-btn {
287
- position: absolute;
288
- width: 45px;
289
- height: 45px;
290
- display: flex;
291
- align-items: center;
292
- justify-content: center;
293
- pointer-events: auto;
294
- background: rgba(92, 154, 248, 0.4);
295
- border: none;
296
- color: rgb(83, 83, 83);
297
- font-size: 26px;
298
- cursor: pointer;
299
- backdrop-filter: blur(1px);
300
- border-radius: 2px;
301
- line-height: 1;
302
- text-align: center;
303
- }
304
-
305
- .nav-btn:hover {
306
- background: rgba(92, 154, 248, 0.7);
307
- }
308
-
309
- .nav-btn.left {
310
- margin-left: 13px;
311
- }
312
-
313
- .nav-btn.right {
314
- right: 23px;
315
- }
316
-
317
- .task-label {
318
- padding-left: 6px;
319
- }
320
-
321
- .hidden {
322
- display: none;
323
- }
@@ -1,391 +0,0 @@
1
- import { Component, ElementRef, ViewChild, Input, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core';
2
- import { DependencyPath, GanttScale, GanttTask, GanttTaskView } from './gantt-lite.model';
3
- import { SlotIndexToDateLabelConverter } from './gantt-lite.helper';
4
- import { GanttLiteBase } from './gantt-lite-base';
5
-
6
- @Component({
7
- selector: 'gantt-lite',
8
- templateUrl: './gantt-lite.component.html',
9
- styleUrls: ['./gantt-lite.component.scss'],
10
- standalone: false,
11
- changeDetection: ChangeDetectionStrategy.OnPush,
12
- })
13
- export class GanttLiteComponent extends GanttLiteBase {
14
- @Input()
15
- public set rawTasks(value: GanttTask[]) {
16
- this._rawTasks = value ?? [];
17
- this.recalculteAll();
18
- }
19
- public get rawTasks(): GanttTask[] {
20
- return this._rawTasks;
21
- }
22
- @ViewChild('ganttBodyScroll') private ganttBodyScroll!: ElementRef<HTMLDivElement>;
23
- @ViewChild('ganttContent') private ganttContent!: ElementRef<HTMLDivElement>;
24
- @ViewChild('ganttScroll') private ganttScroll!: ElementRef<HTMLDivElement>;
25
- @ViewChild('leftPanel') private leftPanel!: ElementRef<HTMLButtonElement>;
26
- @ViewChild('timelineHeader') private timelineHeader!: ElementRef<HTMLDivElement>;
27
- @ViewChild('leftBtn') private leftBtn!: ElementRef<HTMLButtonElement>;
28
- @ViewChild('rightBtn') private rightBtn!: ElementRef<HTMLButtonElement>;
29
- public toolbarHeight: number = 27;
30
- public hoveredDependencyIds = new Set<string>();
31
- public selectedTaskId: string | null = null;
32
- public tooltipTask: GanttTask | null = null;
33
- public tooltipX = 0;
34
- public tooltipY = 0;
35
- public maxSlotsAlert: boolean = false;
36
- public loading: boolean = false;
37
- public showLeftBtn = false;
38
- public showRightBtn = true;
39
- public buttonsHeight: number = 20;
40
- public viewTasks: GanttTaskView[] = [];
41
- private _rawTasks: GanttTask[] = [];
42
- private originDate: number = 0;
43
- private resizing = false;
44
- private scrollInterval: any;
45
- private labelConverter: SlotIndexToDateLabelConverter | null = null;
46
-
47
- constructor(private cdr: ChangeDetectorRef) { super(); }
48
-
49
- // recalculate all Gantt
50
- private recalculteAll(): void {
51
- this.loading = true;
52
- this.cdr.markForCheck();
53
- setTimeout(() => {
54
- const start = performance.now();
55
- this.refreshVisibleColumns();
56
- this.scaleSelected = this.getScaleConfig(this.defaultScale);
57
-
58
- if (this.dataType !== 'duration') {
59
- const base = new Date(Math.min(...this.rawTasks.map((t) => (t.start as Date).getTime())));
60
- this.originDate = this.alignOriginByScale(base, this.defaultScale).getTime();
61
- this.labelConverter = new SlotIndexToDateLabelConverter(
62
- this.dateMode,
63
- this.originDate,
64
- this.scaleSelected.secondes,
65
- this.hasNegativeDependencySpace
66
- );
67
- this.buildTaskSlotsFromDates();
68
- } else {
69
- this.labelConverter = new SlotIndexToDateLabelConverter(
70
- this.dateMode,
71
- this.originDate,
72
- this.scaleSelected.secondes,
73
- this.hasNegativeDependencySpace
74
- );
75
- this.buildTaskSlotsFromDuration();
76
- }
77
- this.detectNegativeDependencySpace();
78
- this.calculateTimelineSlots();
79
- this.getAllDependencyPaths();
80
- this.ganttHeightCalcul();
81
- this.ganttWidthCalcul();
82
- this.recomputeLayout();
83
- this.ganttScroll.nativeElement.style.height = this.layout.scrollHeight + 'px';
84
- this.ganttBodyScroll.nativeElement.style.height = this.layout.bodyHeight + 'px';
85
- this.timelineHeader.nativeElement.style.height = this.headerHeight + 'px';
86
- this.ganttContent.nativeElement.style.height = this.ganttHeight - this.rowHeight + 'px';
87
- this.ganttContent.nativeElement.style.width = this.ganttWidth + 'px';
88
- this.leftPanel.nativeElement.style.width = this.leftPanelWidth + 'px';
89
-
90
- this.buildViewTasks();
91
- setTimeout(() => {
92
- this.recenterSelectedTask();
93
- }, 0);
94
- this.loading = false;
95
- this.cdr.markForCheck();
96
- requestAnimationFrame(() => {
97
- const end = performance.now();
98
- console.log('frame time including render:', end - start, 'ms');
99
- });
100
- }, 0);
101
- }
102
-
103
-
104
- private buildViewTasks(): void {
105
- const h = this.rowHeight / 1.25;
106
-
107
- this.viewTasks = this.tasks.map((t): GanttTaskView => {
108
- return {
109
- ...t,
110
- x: this.getTaskX(t),
111
- y: this.getTaskY(t),
112
- width: this.getTaskWidth(t),
113
- height: h,
114
- };
115
- });
116
- }
117
-
118
- // click on button 10m, hour, months
119
- public setScale(scale: GanttScale): void {
120
- this.defaultScale = scale;
121
- this.recalculteAll();
122
- }
123
-
124
-
125
- // if task was selected scroll back to it
126
- private recenterSelectedTask(): void {
127
- if (!this.selectedTaskId) {
128
- if (!this.ganttScroll) return;
129
- this.ganttScroll.nativeElement.scrollLeft = 0;
130
-
131
- // force header sync
132
- if (this.timelineHeader?.nativeElement) {
133
- this.timelineHeader.nativeElement.scrollLeft = 0;
134
- }
135
- this.onScrolled();
136
- }
137
-
138
- const task = this.tasks.find((t) => t.id === this.selectedTaskId);
139
- if (!task || !this.ganttScroll) return;
140
-
141
- // scroll X
142
- const viewportWidth = this.ganttScroll.nativeElement.clientWidth;
143
- const targetScrollLeft = this.getTaskX(task) - viewportWidth / 2;
144
- this.ganttScroll.nativeElement.scrollTo({ left: Math.max(targetScrollLeft, 0), behavior: 'auto' });
145
-
146
- // scroll Y
147
- const viewportHeight = this.ganttBodyScroll.nativeElement.clientHeight;
148
- const targetTop = task.row * this.rowHeight;
149
- const scrollTop = targetTop - viewportHeight / 2 + this.rowHeight / 2;
150
- this.ganttBodyScroll.nativeElement.scrollTo({ top: Math.max(scrollTop, 0), behavior: 'auto' });
151
-
152
- this.onScrolled();
153
- }
154
-
155
- // detect if there is a need to add the N/A slot in order to be able to show dependency arrow before 0.
156
- private detectNegativeDependencySpace(): void {
157
- const originTasks = this.tasks.filter((t) => (t.startSlot ?? 0) === 0);
158
-
159
- this.hasNegativeDependencySpace = originTasks.some((task) => {
160
- const fromRight = this.getRawTaskX(task) + this.getTaskWidth(task);
161
- return (task.dependencies ?? []).some((depId: any) => {
162
- const target = this.tasks.find((t) => t.id === depId);
163
- if (!target) return false;
164
-
165
- const toLeft = this.getRawTaskX(target);
166
- // true if dependency goes "backwards"
167
- return toLeft < fromRight;
168
- });
169
- });
170
- }
171
-
172
- // scroll buttons left/right => click small go, stay clicked scroll a lot
173
- public startScroll(direction: 'left' | 'right'): void {
174
- // instant first move
175
- this.scrollInterval = setInterval(() => {
176
- this.ganttScroll.nativeElement.scrollLeft += direction === 'left' ? -100 : 100;
177
- this.onScrolled();
178
- }, 50);
179
- }
180
-
181
- // update buttons left/right after a scroll.
182
- private onScrolled(): void {
183
- const el = this.ganttScroll.nativeElement;
184
- const maxScrollLeft = el.scrollWidth - el.clientWidth;
185
- // left button
186
- this.showLeftBtn = el.scrollLeft > 0;
187
- if (this.leftBtn?.nativeElement) {
188
- if (this.showLeftBtn) {
189
- this.leftBtn.nativeElement.style.display = 'block';
190
- this.leftBtn.nativeElement.style.left = this.leftPanel.nativeElement.style.width;
191
- } else {
192
- this.leftBtn.nativeElement.style.display = 'none';
193
- this.leftBtn.nativeElement.style.left = this.leftPanel.nativeElement.style.width;
194
- }
195
- }
196
- // right button
197
- this.showRightBtn = el.scrollLeft < maxScrollLeft - 1 || el.scrollLeft === 0; // small tolerance
198
- if (this.rightBtn?.nativeElement) {
199
- if (this.showRightBtn) {
200
- this.rightBtn.nativeElement.style.display = 'block';
201
- } else {
202
- this.rightBtn.nativeElement.style.display = 'none';
203
- }
204
- }
205
- if (!this.showLeftBtn || !this.showRightBtn) {
206
- this.stopScroll();
207
- }
208
- }
209
-
210
- public stopScroll(): void{
211
- clearInterval(this.scrollInterval);
212
- }
213
-
214
- // to sync header and grid
215
- public onHorizontalScroll(event: Event): void {
216
- this.timelineHeader.nativeElement.scrollLeft = (event.target as HTMLElement).scrollLeft;
217
- }
218
-
219
- // highlight dependencies (arrows)
220
- private updateHoveredDependencies(task: GanttTask | null): void {
221
- this.hoveredDependencyIds.clear();
222
- if (!task) {
223
- return;
224
- }
225
-
226
- // outgoing dependencies
227
- for (const dep of task.dependencies ?? []) {
228
- this.hoveredDependencyIds.add(`${dep}->${task.id}`);
229
- }
230
-
231
- // incoming dependencies
232
- for (const t of this.tasks) {
233
- if ((t.dependencies ?? []).includes(task.id)) {
234
- this.hoveredDependencyIds.add(`${task.id}->${t.id}`);
235
- }
236
- }
237
- }
238
-
239
- // select task left and right panel
240
- public selectTask(task: GanttTask, scroll: boolean): void {
241
- if (this.selectedTaskId === task.id) {
242
- this.selectedTaskId = null;
243
- this.hoveredDependencyIds.clear();
244
- this.hideTooltip();
245
- this.onScrolled();
246
- return;
247
- }
248
-
249
- this.selectedTaskId = task.id;
250
- this.updateHoveredDependencies(task);
251
- this.onScrolled();
252
- if (!scroll || !this.ganttScroll) return;
253
-
254
- const container = this.ganttScroll.nativeElement as HTMLElement;
255
- const viewportWidth = container.clientWidth;
256
- const targetScrollLeft = this.getTaskX(task) - viewportWidth / 2;
257
-
258
- container.scrollTo({
259
- left: Math.max(targetScrollLeft, 0),
260
- behavior: 'smooth',
261
- });
262
- this.onScrolled();
263
- }
264
-
265
- // resize between panel left and right
266
- public startResize(event: MouseEvent): void {
267
- event.preventDefault();
268
- this.resizing = true;
269
- const containerRect = this.ganttBodyScroll.nativeElement.getBoundingClientRect();
270
- const left = containerRect.left;
271
- const min = 50;
272
- const max = containerRect.width - 75;
273
- const moveHandler = (e: MouseEvent): void => {
274
- if (!this.resizing) return;
275
- const x = e.clientX - left;
276
- const width = x < min ? min : x > max ? max : x;
277
-
278
- // direct DOM update (no Angular)
279
- this.leftPanel.nativeElement.style.width = width + 'px';
280
- if (this.leftBtn?.nativeElement) {
281
- this.leftBtn.nativeElement.style.left = `${width}px`;
282
- }
283
- };
284
-
285
- const upHandler = (): void => {
286
- this.resizing = false;
287
- window.removeEventListener('mousemove', moveHandler);
288
- window.removeEventListener('mouseup', upHandler);
289
- };
290
-
291
- window.addEventListener('mousemove', moveHandler);
292
- window.addEventListener('mouseup', upHandler);
293
- }
294
-
295
- public showTooltip(event: MouseEvent, task: GanttTask): void {
296
- this.tooltipTask = task;
297
- const tooltip = document.querySelector('.global-tooltip') as HTMLElement;
298
- if (!tooltip) return;
299
- tooltip.style.left = `${event.clientX + 10}px`;
300
- tooltip.style.top = `${event.clientY - 50}px`;
301
- tooltip.style.display = 'block';
302
- }
303
-
304
- public hideTooltip(): void {
305
- this.tooltipTask = null;
306
- const tooltip = document.querySelector('.global-tooltip') as HTMLElement;
307
- if (!tooltip) return;
308
- tooltip.style.display = 'none';
309
- }
310
-
311
- private buildTaskSlotsFromDates(): void {
312
- const slotMs = this.scaleSelected.secondes * 1000;
313
- this.tasks = this.rawTasks.map((t) => {
314
- if (!(t.start instanceof Date) || !(t.end instanceof Date)) {
315
- throw new Error(`Task "${t.id}" must have Date start/end values.`);
316
- }
317
-
318
- const startSlot = ((t.start as Date).getTime() - this.originDate) / slotMs;
319
- const endSlot = ((t.end as Date).getTime() - this.originDate) / slotMs;
320
- return { ...t, startSlot, endSlot };
321
- });
322
- }
323
-
324
- private buildTaskSlotsFromDuration(): void {
325
- this.tasks = this.rawTasks.map((t) => {
326
- if (typeof t.start !== 'number' || typeof t.end !== 'number') {
327
- throw new Error(`Task "${t.id}" must have numeric start/end values expressed in minutes.`);
328
- }
329
-
330
- const startSlot = t.start / this.scaleSelected.secondes;
331
- const endSlot = t.end / this.scaleSelected.secondes;
332
- return { ...t, startSlot, endSlot };
333
- });
334
- }
335
-
336
- // get all slots (timeline)
337
- private calculateTimelineSlots(): void {
338
- const maxSlot = Math.max(...this.tasks.map(t => t.endSlot ?? 0));
339
- if (maxSlot > 53000) {
340
- this.maxSlotsAlert = true;
341
- this.slots = [];
342
- return;
343
- }
344
- this.maxSlotsAlert = false;
345
- const buffer = [];
346
- let offsetX = 0;
347
-
348
- if (this.hasNegativeDependencySpace) {
349
- buffer.push({
350
- index: -1,
351
- x: 0,
352
- width: this.negativeSlotWidth,
353
- label: 'N/A'
354
- });
355
- offsetX = this.negativeSlotWidth;
356
- }
357
-
358
- for (let i = 0; i <= maxSlot + 2; i++) {
359
- buffer.push({
360
- index: i,
361
- x: offsetX + i * this.scaleSelected.px,
362
- width: this.scaleSelected.px,
363
- label: this.getSlotLabel(i) ?? 'Error',
364
- });
365
- }
366
-
367
- this.slots = buffer;
368
- }
369
-
370
- // get all paths of arrows
371
- protected getAllDependencyPaths(): void {
372
- this.dependencyPaths = this.tasks.flatMap((task): DependencyPath[] =>
373
- (task.dependencies ?? []).map(
374
- (dep: any): DependencyPath => ({
375
- from: dep,
376
- to: task.id,
377
- id: `${dep}->${task.id}`,
378
- path: this.getDependencyPath(dep, task.id),
379
- })
380
- )
381
- );
382
- }
383
-
384
- // get label for date header
385
- private getSlotLabel(index: number): string {
386
- if (this.dataType === 'calendar') {
387
- return this.labelConverter?.getLabelCalendar(index, this.defaultScale) ?? 'Error';
388
- }
389
- return this.labelConverter?.getLabelDuration(index, this.defaultScale) ?? 'Error';
390
- }
391
- }