x4js 2.3.28 → 2.3.30
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 +1 -1
- package/aicontext.md +385 -21
- package/package.json +8 -3
- package/src/components/assets/icons.ts +2 -0
- package/src/components/bargraph/bargraph.module.scss +44 -0
- package/src/components/bargraph/bargraph.ts +362 -0
- package/src/components/base.scss +71 -1
- package/src/components/calendar/calendar.module.scss +6 -5
- package/src/components/calendar/calendar.ts +14 -8
- package/src/components/canvas/canvas.ts +5 -4
- package/src/components/carousel/carousel.module.scss +165 -0
- package/src/components/carousel/carousel.ts +423 -0
- package/src/components/chart/chart.module.scss +8 -0
- package/src/components/chart/chart.ts +170 -12
- package/src/components/combobox/combobox.module.scss +2 -1
- package/src/components/components.ts +3 -0
- package/src/components/dialog/dialog.module.scss +2 -0
- package/src/components/dialog/dialog.ts +1 -0
- package/src/components/gantt/gantt.module.scss +462 -0
- package/src/components/gantt/gantt.ts +1254 -0
- package/src/components/knob/knob.module.scss +113 -0
- package/src/components/knob/knob.ts +355 -0
- package/src/components/mapview/mapview.module.scss +177 -0
- package/src/components/mapview/mapview.ts +764 -0
- package/src/components/menu/menu.module.scss +4 -6
- package/src/components/menu/menu.ts +4 -2
- package/src/components/planner/planner.module.scss +688 -0
- package/src/components/planner/planner.ts +1792 -0
- package/src/components/popup/popup.ts +29 -3
- package/src/components/radio/radio.module.scss +4 -1
- package/src/components/shared.scss +2 -1
- package/src/components/slider/slider.module.scss +22 -20
- package/src/components/switch/switch.module.scss +68 -44
- package/src/components/switch/switch.ts +6 -3
- package/src/components/tag/tag.module.scss +4 -4
- package/src/components/textedit/textedit.module.scss +5 -2
- package/src/components/themes.scss +27 -6
- package/src/components/tooltips/tooltips.scss +8 -0
- package/src/components/tooltips/tooltips.ts +31 -0
- package/src/core/core_i18n.ts +65 -0
- package/src/core/core_tools.ts +44 -0
- package/src/x4doc.ts +3 -0
- package/src/components/menu/caret-right-solid.svg +0 -1
|
@@ -0,0 +1,1254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ___ ___ __
|
|
3
|
+
* \ \/ / / _
|
|
4
|
+
* \ / /_| |_
|
|
5
|
+
* / \____ _|
|
|
6
|
+
* /__/\__\ |_|
|
|
7
|
+
*
|
|
8
|
+
* @file gantt.ts
|
|
9
|
+
* @author Etienne Cochard
|
|
10
|
+
*
|
|
11
|
+
* @copyright (c) 2026 R-libre ingenierie
|
|
12
|
+
*
|
|
13
|
+
* Use of this source code is governed by an MIT-style license
|
|
14
|
+
* that can be found in the LICENSE file or at https://opensource.org/licenses/MIT.
|
|
15
|
+
**/
|
|
16
|
+
|
|
17
|
+
import { Component, ComponentEvent, ComponentEvents, ComponentProps, EvClick, EvContextMenu, EvDblClick, EvSelectionChange } from '../../core/component';
|
|
18
|
+
import { EventCallback } from '../../core/core_events';
|
|
19
|
+
import { _tr } from '../../core/core_i18n';
|
|
20
|
+
import { SvgBuilder, SvgComponent } from '../../core/core_svg';
|
|
21
|
+
import { UnsafeHtml, clamp, date_calc_weeknum, date_format, date_week_start, formatDate, formatDateTime, formatPercent, formatUnit } from '../../core/core_tools';
|
|
22
|
+
import { Icon } from '../icon/icon';
|
|
23
|
+
import { CSizer } from '../sizers/sizer';
|
|
24
|
+
import icons from '../assets/icons';
|
|
25
|
+
|
|
26
|
+
import "./gantt.module.scss";
|
|
27
|
+
|
|
28
|
+
export type GanttID = string | number;
|
|
29
|
+
export type GanttScale = "hour" | "day" | "week" | "month";
|
|
30
|
+
|
|
31
|
+
export interface GanttTask {
|
|
32
|
+
id: GanttID;
|
|
33
|
+
title: string;
|
|
34
|
+
start?: Date; // required for a task; a group takes it from its tasks
|
|
35
|
+
end?: Date; // its last day, included, when it has no hours; else the hour it ends. none: the day of start
|
|
36
|
+
progress?: number; // 0 to 100: the part of the bar filled
|
|
37
|
+
milestone?: boolean; // a diamond in the middle of the task: of its day, for a date without hours
|
|
38
|
+
children?: GanttTask[]; // a group: its bar goes from the first start of its tasks to their last end
|
|
39
|
+
open?: boolean; // a group: its tasks shown; true by default
|
|
40
|
+
deps?: GanttID[]; // the tasks that end before this one starts: an arrow from each of them
|
|
41
|
+
color?: string; // a CSS colour for its bar; a group gives it to its tasks that have none. none: the theme
|
|
42
|
+
tooltip?: string; // on its line and its bar, as html (see initTooltips)
|
|
43
|
+
cls?: string; // on its line, its lane and its bar
|
|
44
|
+
data?: any;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface GanttColumn {
|
|
48
|
+
id: string; // "title", "start", "end", "duration", "progress", or a field of the task
|
|
49
|
+
title?: string; // none: for the ids above, their title in the language (_tr.global.gantt)
|
|
50
|
+
width?: number; // 100 by default
|
|
51
|
+
align?: "left" | "center" | "right";
|
|
52
|
+
formatter?: ( task: GanttTask ) => string | UnsafeHtml;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface EvGanttChange extends ComponentEvent {
|
|
56
|
+
readonly task: GanttTask; // as it was: the new values are written in it after the event, unless ev.preventDefault( )
|
|
57
|
+
readonly start: Date;
|
|
58
|
+
readonly end: Date;
|
|
59
|
+
readonly progress: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface EvGanttScale extends ComponentEvent {
|
|
63
|
+
readonly scale: GanttScale;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface GanttEvents extends ComponentEvents {
|
|
67
|
+
click: EvClick; // context: the task
|
|
68
|
+
dblClick: EvDblClick; // context: the task
|
|
69
|
+
contextMenu: EvContextMenu; // context: the task
|
|
70
|
+
selectionChange: EvSelectionChange;
|
|
71
|
+
change: EvGanttChange;
|
|
72
|
+
scaleChange: EvGanttScale; // the zoom took another scale
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface GanttProps extends Omit<ComponentProps,"content"> {
|
|
76
|
+
tasks?: GanttTask[];
|
|
77
|
+
columns?: GanttColumn[]; // the list on the left; the titles alone by default
|
|
78
|
+
scale?: GanttScale; // what a column of the chart is: "day" by default; the zoom changes it
|
|
79
|
+
start?: Date; // the first day shown; else the one of the first task
|
|
80
|
+
end?: Date; // the last day shown; else the one of the last task
|
|
81
|
+
rowHeight?: number; // 32 by default
|
|
82
|
+
readonly?: boolean; // the bars cannot be moved, stretched nor their progress changed; the tasks are still clicked
|
|
83
|
+
click?: EventCallback<EvClick>;
|
|
84
|
+
dblClick?: EventCallback<EvDblClick>;
|
|
85
|
+
contextMenu?: EventCallback<EvContextMenu>;
|
|
86
|
+
selectionChange?: EventCallback<EvSelectionChange>;
|
|
87
|
+
change?: EventCallback<EvGanttChange>;
|
|
88
|
+
scaleChange?: EventCallback<EvGanttScale>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// a task shown: its line, its place in days (see toDays)
|
|
92
|
+
interface GanttRow {
|
|
93
|
+
task: GanttTask;
|
|
94
|
+
level: number; // 0 at the top, 1 in a group...
|
|
95
|
+
index: number; // its line, from the top
|
|
96
|
+
from: number; // undefined: a task without start, no bar
|
|
97
|
+
to: number;
|
|
98
|
+
progress: number;
|
|
99
|
+
color: string; // its own, else the one of its group
|
|
100
|
+
line: Component; // in the list
|
|
101
|
+
lane: Component; // in the chart
|
|
102
|
+
bar: Component;
|
|
103
|
+
fill: Component; // the progress, in the bar
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface GanttDrag {
|
|
107
|
+
row: GanttRow;
|
|
108
|
+
mode: "move" | "start" | "end" | "progress";
|
|
109
|
+
x: number; // where the pointer went down
|
|
110
|
+
from: number; // the row as it was
|
|
111
|
+
to: number;
|
|
112
|
+
progress: number;
|
|
113
|
+
moved: boolean; // the pointer went 3px away: a drag, not a click
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// the days in view, [from, to[
|
|
117
|
+
interface GanttView {
|
|
118
|
+
from: number;
|
|
119
|
+
to: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// the last notch of the zoom: the day under the pointer, and where the pointer and the scroll were
|
|
123
|
+
interface GanttZoom {
|
|
124
|
+
day: number;
|
|
125
|
+
x: number;
|
|
126
|
+
scroll: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// a cell of the header: [from, to[ in days
|
|
130
|
+
interface GanttCell {
|
|
131
|
+
from: number;
|
|
132
|
+
to: number;
|
|
133
|
+
text: string;
|
|
134
|
+
weekend?: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const DAY = 86400000;
|
|
138
|
+
const GAP = 8; // the arrows: their length out of a bar, and into the next one
|
|
139
|
+
const DIAMOND = 8; // half the width of a milestone
|
|
140
|
+
|
|
141
|
+
// the width of a day, by scale
|
|
142
|
+
const DAY_WIDTHS: Record<GanttScale, number> = { hour: 24 * 28, day: 32, week: 14, month: 4 };
|
|
143
|
+
|
|
144
|
+
// the zoom: a month of 45px at least, an hour of 60px at most
|
|
145
|
+
const MIN_DW = 1.5;
|
|
146
|
+
const MAX_DW = 24 * 60;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* a date as a number of days: the days from 1/1/1970 to its day in the local calendar, plus the part
|
|
150
|
+
* of its day gone at its hour. A day of 23 or 25 hours (summer time) counts as one all the same
|
|
151
|
+
*/
|
|
152
|
+
|
|
153
|
+
function toDays( date: Date ): number {
|
|
154
|
+
return Date.UTC( date.getFullYear( ), date.getMonth( ), date.getDate( ) ) / DAY + ( date.getHours( ) * 60 + date.getMinutes( ) ) / 1440;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* the date of a number of days (toDays), to the minute
|
|
159
|
+
*/
|
|
160
|
+
|
|
161
|
+
function fromDays( days: number ): Date {
|
|
162
|
+
const day = Math.floor( days );
|
|
163
|
+
const utc = new Date( day * DAY );
|
|
164
|
+
return new Date( utc.getUTCFullYear( ), utc.getUTCMonth( ), utc.getUTCDate( ), 0, Math.round( ( days - day ) * 1440 ) );
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isWhole( days: number ) {
|
|
168
|
+
return Math.abs( days - Math.round( days ) ) < 1e-6;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function hasTime( date: Date ) {
|
|
172
|
+
return date.getHours( )!=0 || date.getMinutes( )!=0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isGroup( task: GanttTask ) {
|
|
176
|
+
return !!task.children && task.children.length > 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* the end of a task from the day it stops: at midnight, the day before (its last day, included); else that hour
|
|
181
|
+
*/
|
|
182
|
+
|
|
183
|
+
function toEnd( to: number ): Date {
|
|
184
|
+
return isWhole( to ) ? fromDays( Math.round( to ) - 1 ) : fromDays( to );
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* a date of a task as text: its day, and its time when it has one
|
|
189
|
+
*/
|
|
190
|
+
|
|
191
|
+
export function dateText( date: Date ) {
|
|
192
|
+
return hasTime( date ) ? formatDateTime( date ) : formatDate( date );
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// "3 j", "5 h" in fr: in hours under a day
|
|
196
|
+
function durationText( days: number ) {
|
|
197
|
+
return days < 1 ? formatUnit( days * 24, "hour", 1 ) : formatUnit( days, "day", 1 );
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* the scale of a width of a day: the cells of the second line of the header stay readable
|
|
202
|
+
* (an hour of 20px, a day of 18px, a week of 35px)
|
|
203
|
+
*/
|
|
204
|
+
|
|
205
|
+
function scaleOf( dw: number ): GanttScale {
|
|
206
|
+
if( dw >= 24 * 20 ) {
|
|
207
|
+
return "hour";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if( dw >= 18 ) {
|
|
211
|
+
return "day";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return dw >= 5 ? "week" : "month";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Not in `x4js`: import it from `"x4js/gantt"`.
|
|
219
|
+
*
|
|
220
|
+
* Tasks on a calendar: their list on the left (a tree, with columns), a bar for each one on the
|
|
221
|
+
* days it lasts, an arrow from each task it waits for. The pointer moves a bar, stretches it by
|
|
222
|
+
* its ends, sets its progress by the mark under it. Ctrl+wheel on the chart zooms it: the scale
|
|
223
|
+
* follows the width of a day.
|
|
224
|
+
*
|
|
225
|
+
* Read only (readonly, setReadOnly): no bar is moved, stretched nor its progress changed. Disabled
|
|
226
|
+
* (disabled, enable( false )): nothing, faded as a Box (--box-disabled-opacity).
|
|
227
|
+
*
|
|
228
|
+
* A date without hours is a whole day: `end` is the last day of the task, included.
|
|
229
|
+
*
|
|
230
|
+
* @cssvar
|
|
231
|
+
* ```
|
|
232
|
+
* --gantt-background
|
|
233
|
+
* --gantt-border
|
|
234
|
+
* --gantt-border-focus
|
|
235
|
+
* --gantt-header-background
|
|
236
|
+
* --gantt-header-color
|
|
237
|
+
* --gantt-line-color
|
|
238
|
+
* --gantt-major-line-color
|
|
239
|
+
* --gantt-weekend-background
|
|
240
|
+
* --gantt-today-color
|
|
241
|
+
* --gantt-row-background-sel
|
|
242
|
+
* --gantt-row-color-sel
|
|
243
|
+
* --gantt-lane-background-sel
|
|
244
|
+
* --gantt-icon-color
|
|
245
|
+
* --gantt-bar-background
|
|
246
|
+
* --gantt-bar-border
|
|
247
|
+
* --gantt-bar-progress
|
|
248
|
+
* --gantt-bar-label
|
|
249
|
+
* --gantt-handle-color
|
|
250
|
+
* --gantt-group-background
|
|
251
|
+
* --gantt-milestone-background
|
|
252
|
+
* --gantt-link-color
|
|
253
|
+
* --gantt-link-color-sel
|
|
254
|
+
* ```
|
|
255
|
+
*/
|
|
256
|
+
|
|
257
|
+
export class Gantt extends Component<GanttProps, GanttEvents> {
|
|
258
|
+
static classPrefix = "x4";
|
|
259
|
+
|
|
260
|
+
private _tasks: GanttTask[];
|
|
261
|
+
private _columns: GanttColumn[];
|
|
262
|
+
private _widths: number[];
|
|
263
|
+
private _scale: GanttScale;
|
|
264
|
+
private _rowHeight: number;
|
|
265
|
+
private _readonly: boolean;
|
|
266
|
+
|
|
267
|
+
private _rows: GanttRow[] = [];
|
|
268
|
+
private _byId = new Map<GanttID, GanttRow>( );
|
|
269
|
+
private _selection: GanttID;
|
|
270
|
+
private _drag: GanttDrag;
|
|
271
|
+
private _zoom: GanttZoom;
|
|
272
|
+
|
|
273
|
+
private _origin: number; // the first day shown (toDays)
|
|
274
|
+
private _end: number; // the day after the last one
|
|
275
|
+
private _dw: number; // the width of a day, in px
|
|
276
|
+
|
|
277
|
+
private _body: Component;
|
|
278
|
+
private _head: Component;
|
|
279
|
+
private _top: Component;
|
|
280
|
+
private _bottom: Component;
|
|
281
|
+
private _list: Component;
|
|
282
|
+
private _chart: Component;
|
|
283
|
+
private _grid: SvgComponent;
|
|
284
|
+
private _lanes: Component;
|
|
285
|
+
private _links: SvgComponent;
|
|
286
|
+
|
|
287
|
+
constructor( props: GanttProps ) {
|
|
288
|
+
super( props );
|
|
289
|
+
|
|
290
|
+
this._tasks = props.tasks ?? [];
|
|
291
|
+
this._columns = props.columns ?? [ { id: "title", width: 240 } ];
|
|
292
|
+
this._widths = this._columns.map( col => col.width ?? 100 );
|
|
293
|
+
this._scale = props.scale ?? "day";
|
|
294
|
+
this._dw = DAY_WIDTHS[this._scale];
|
|
295
|
+
this._rowHeight = props.rowHeight ?? 32;
|
|
296
|
+
|
|
297
|
+
this.setAttribute( "tabindex", 0 );
|
|
298
|
+
this._readonly = !!props.readonly;
|
|
299
|
+
this.setClass( "readonly", this._readonly );
|
|
300
|
+
this.setStyleVariable( "--gantt-row-height", this._rowHeight + "px" );
|
|
301
|
+
|
|
302
|
+
this.setContent( this._body = new Component( { cls: "body", content: [
|
|
303
|
+
new Component( { cls: "corner", content: this._columns.map( ( col, i ) => this._makeTitle( col, i ) ) } ),
|
|
304
|
+
this._head = new Component( { cls: "head", content: [
|
|
305
|
+
this._top = new Component( { cls: "line top" } ),
|
|
306
|
+
this._bottom = new Component( { cls: "line bottom" } ),
|
|
307
|
+
] } ),
|
|
308
|
+
this._list = new Component( { cls: "list" } ),
|
|
309
|
+
this._chart = new Component( { cls: "chart", content: [
|
|
310
|
+
this._grid = new SvgComponent( { cls: "grid", attrs: { preserveAspectRatio: "none" } } ),
|
|
311
|
+
this._lanes = new Component( { cls: "lanes" } ),
|
|
312
|
+
this._links = new SvgComponent( { cls: "links" } ),
|
|
313
|
+
] } ),
|
|
314
|
+
] } ) );
|
|
315
|
+
|
|
316
|
+
this.mapPropEvents( props, "click", "dblClick", "contextMenu", "selectionChange", "change", "scaleChange" );
|
|
317
|
+
|
|
318
|
+
this.addDOMEvent( "click", ( ev ) => this._onClick( ev ) );
|
|
319
|
+
this.addDOMEvent( "dblclick", ( ev ) => this._onDblClick( ev ) );
|
|
320
|
+
this.addDOMEvent( "contextmenu", ( ev ) => this._onContextMenu( ev ) );
|
|
321
|
+
this.addDOMEvent( "keydown", ( ev ) => this._onKey( ev ) );
|
|
322
|
+
this.addDOMEvent( "wheel", ( ev ) => this._onWheel( ev ) );
|
|
323
|
+
this.addDOMEvent( "resized", ( ) => this._onResize( ) );
|
|
324
|
+
|
|
325
|
+
this._chart.addDOMEvent( "pointerdown", ( ev ) => this._onPointerDown( ev ) );
|
|
326
|
+
this._chart.addDOMEvent( "pointermove", ( ev ) => this._onPointerMove( ev ) );
|
|
327
|
+
this._chart.addDOMEvent( "pointerup", ( ev ) => this._onPointerUp( ev ) );
|
|
328
|
+
this._chart.addDOMEvent( "pointercancel", ( ) => this._onPointerCancel( ) );
|
|
329
|
+
this._chart.addDOMEvent( "lostpointercapture", ( ) => this._onPointerCancel( ) );
|
|
330
|
+
|
|
331
|
+
this._render( );
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
setTasks( tasks: GanttTask[] ) {
|
|
335
|
+
this._tasks = tasks;
|
|
336
|
+
this._render( null );
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* the width of a day of this scale; the date at the left of the chart stays there
|
|
341
|
+
*/
|
|
342
|
+
|
|
343
|
+
setScale( scale: GanttScale ) {
|
|
344
|
+
const left = this._origin + this._body.dom.scrollLeft / this._dw;
|
|
345
|
+
|
|
346
|
+
this._scale = scale;
|
|
347
|
+
this._dw = DAY_WIDTHS[scale];
|
|
348
|
+
this._render( this._viewAt( left, 0 ) );
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* the bars cannot be moved, stretched nor their progress changed; the tasks are still clicked
|
|
353
|
+
*/
|
|
354
|
+
|
|
355
|
+
setReadOnly( ro: boolean ) {
|
|
356
|
+
this._readonly = ro;
|
|
357
|
+
this.setClass( "readonly", ro );
|
|
358
|
+
this._render( ); // their handles
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
getScale( ): GanttScale {
|
|
362
|
+
return this._scale;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* a task shown (not in a group closed); null: none
|
|
367
|
+
*/
|
|
368
|
+
|
|
369
|
+
selectTask( id: GanttID ) {
|
|
370
|
+
const row = this._byId.get( id );
|
|
371
|
+
this._select( row, false );
|
|
372
|
+
|
|
373
|
+
if( row ) {
|
|
374
|
+
this._ensureVisible( row );
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
getSelection( ): GanttID {
|
|
379
|
+
return this._selection;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* the date in the middle of the chart
|
|
384
|
+
*/
|
|
385
|
+
|
|
386
|
+
scrollToDate( date: Date ) {
|
|
387
|
+
const body = this._body.dom;
|
|
388
|
+
body.scrollLeft = this._x( toDays( date ) ) - ( body.clientWidth - this._listWidth( ) ) / 2;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// :: RENDER ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* view: the days to show, the first one at the left of the chart in view, not before the
|
|
395
|
+
* first day of the chart; by default the days in view stay where they are. null: the days
|
|
396
|
+
* of the tasks alone
|
|
397
|
+
*/
|
|
398
|
+
|
|
399
|
+
private _render( view = this._view( ) ) {
|
|
400
|
+
this._rows = [];
|
|
401
|
+
this._flatten( this._tasks, 0 );
|
|
402
|
+
this._byId = new Map( this._rows.map( row => [ row.task.id, row ] ) );
|
|
403
|
+
|
|
404
|
+
const range = this._calcRange( view );
|
|
405
|
+
this._origin = range.origin;
|
|
406
|
+
this._end = range.end;
|
|
407
|
+
|
|
408
|
+
const top = this._topCells( );
|
|
409
|
+
const bottom = this._bottomCells( );
|
|
410
|
+
|
|
411
|
+
this._top.setContent( top.map( cell => this._makeCell( cell ) ) );
|
|
412
|
+
this._bottom.setContent( bottom.map( cell => this._makeCell( cell ) ) );
|
|
413
|
+
this._list.setContent( this._rows.map( row => row.line = this._makeLine( row ) ) );
|
|
414
|
+
|
|
415
|
+
this._renderGrid( top, bottom );
|
|
416
|
+
this._lanes.setContent( this._rows.map( row => row.lane = this._makeLane( row ) ) );
|
|
417
|
+
this._drawLinks( );
|
|
418
|
+
this._layout( );
|
|
419
|
+
|
|
420
|
+
if( view ) {
|
|
421
|
+
this._body.dom.scrollLeft = this._x( view.from );
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* the tasks shown, one under the other: the tasks of a group after it, when it is open
|
|
427
|
+
*/
|
|
428
|
+
|
|
429
|
+
private _flatten( tasks: GanttTask[], level: number, color?: string ) {
|
|
430
|
+
tasks.forEach( task => {
|
|
431
|
+
const row = { task, level, index: this._rows.length, color: task.color ?? color } as GanttRow;
|
|
432
|
+
|
|
433
|
+
const m = this._measure( task );
|
|
434
|
+
if( m ) {
|
|
435
|
+
row.from = m.from;
|
|
436
|
+
row.to = m.to;
|
|
437
|
+
row.progress = m.progress;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
this._rows.push( row );
|
|
441
|
+
|
|
442
|
+
if( isGroup( task ) && task.open!==false ) {
|
|
443
|
+
this._flatten( task.children, level + 1, row.color );
|
|
444
|
+
}
|
|
445
|
+
} );
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* the days of a task, [from, to[, and its progress; a group: those of its tasks, its progress
|
|
450
|
+
* weighted by their length. null: a task without start
|
|
451
|
+
*/
|
|
452
|
+
|
|
453
|
+
private _measure( task: GanttTask ): { from: number, to: number, progress: number } {
|
|
454
|
+
if( isGroup( task ) ) {
|
|
455
|
+
let from = Infinity;
|
|
456
|
+
let to = -Infinity;
|
|
457
|
+
let work = 0;
|
|
458
|
+
let length = 0;
|
|
459
|
+
|
|
460
|
+
task.children.forEach( child => {
|
|
461
|
+
const m = this._measure( child );
|
|
462
|
+
if( m ) {
|
|
463
|
+
from = Math.min( from, m.from );
|
|
464
|
+
to = Math.max( to, m.to );
|
|
465
|
+
|
|
466
|
+
if( !child.milestone ) {
|
|
467
|
+
work += m.progress * ( m.to - m.from );
|
|
468
|
+
length += m.to - m.from;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
} );
|
|
472
|
+
|
|
473
|
+
return isFinite( from ) ? { from, to, progress: length ? work / length : 0 } : null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if( !task.start ) {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const end = task.end ?? task.start;
|
|
481
|
+
const from = toDays( task.start );
|
|
482
|
+
const to = hasTime( end ) ? toDays( end ) : toDays( end ) + 1;
|
|
483
|
+
return { from, to: Math.max( from, to ), progress: task.progress ?? 0 };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* the days shown: from the column of the first task to the end of the unit of the first line
|
|
488
|
+
* of the header after the last one (or after the last day in view), and more units until the
|
|
489
|
+
* chart fills the width of the view
|
|
490
|
+
*/
|
|
491
|
+
|
|
492
|
+
private _calcRange( view: GanttView ) {
|
|
493
|
+
let min = Infinity;
|
|
494
|
+
let max = -Infinity;
|
|
495
|
+
|
|
496
|
+
this._rows.forEach( row => {
|
|
497
|
+
if( row.from!==undefined ) {
|
|
498
|
+
min = Math.min( min, row.from );
|
|
499
|
+
max = Math.max( max, row.to );
|
|
500
|
+
}
|
|
501
|
+
} );
|
|
502
|
+
|
|
503
|
+
// the chart goes to the end of what is in view (the zoom keeps the day under the pointer),
|
|
504
|
+
// not before the first task
|
|
505
|
+
if( view ) {
|
|
506
|
+
max = Math.max( max, view.to );
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if( this.props.start ) {
|
|
510
|
+
min = toDays( this.props.start );
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if( this.props.end ) {
|
|
514
|
+
max = toDays( this.props.end ) + 1;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if( !isFinite( min ) ) {
|
|
518
|
+
min = Math.floor( toDays( new Date( ) ) );
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if( !isFinite( max ) ) {
|
|
522
|
+
max = min + 1;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const origin = this._floor( min );
|
|
526
|
+
let end = this._next( max );
|
|
527
|
+
|
|
528
|
+
const width = this._body.dom.clientWidth - this._listWidth( );
|
|
529
|
+
while( ( end - origin ) * this._dw < width ) {
|
|
530
|
+
end = this._next( end );
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return { origin, end };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// the first day shown for a day: its midnight (by hours), its month (by months), else its monday
|
|
537
|
+
private _floor( days: number ): number {
|
|
538
|
+
const date = fromDays( days );
|
|
539
|
+
|
|
540
|
+
switch( this._scale ) {
|
|
541
|
+
case "hour": return Math.floor( days );
|
|
542
|
+
case "month": return toDays( new Date( date.getFullYear( ), date.getMonth( ), 1 ) );
|
|
543
|
+
default: return Math.floor( toDays( date_week_start( date ) ) );
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// the start of the unit of the first line of the header after a day: the next day, month, year
|
|
548
|
+
private _next( days: number ): number {
|
|
549
|
+
const date = fromDays( days );
|
|
550
|
+
|
|
551
|
+
switch( this._scale ) {
|
|
552
|
+
case "hour": return Math.floor( days ) + 1;
|
|
553
|
+
case "month": return toDays( new Date( date.getFullYear( ) + 1, 0, 1 ) );
|
|
554
|
+
default: return toDays( new Date( date.getFullYear( ), date.getMonth( ) + 1, 1 ) );
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// the first line of the header: the days (by hours), the months, the years (by months)
|
|
559
|
+
private _topCells( ): GanttCell[] {
|
|
560
|
+
const cells: GanttCell[] = [];
|
|
561
|
+
|
|
562
|
+
for( let day = this._origin; day < this._end; ) {
|
|
563
|
+
const next = this._next( day );
|
|
564
|
+
const date = fromDays( day );
|
|
565
|
+
|
|
566
|
+
let text: string;
|
|
567
|
+
switch( this._scale ) {
|
|
568
|
+
case "hour": text = date_format( date, { weekday: "short", day: "numeric", month: "short" } ); break;
|
|
569
|
+
case "month": text = "" + date.getFullYear( ); break;
|
|
570
|
+
default: text = date_format( date, { month: "long", year: "numeric" } ); break;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
cells.push( { from: day, to: next, text } );
|
|
574
|
+
day = next;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return cells;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// the second line: the hours, the days, the weeks (from monday) or the months
|
|
581
|
+
private _bottomCells( ): GanttCell[] {
|
|
582
|
+
const cells: GanttCell[] = [];
|
|
583
|
+
|
|
584
|
+
for( let day = this._origin; day < this._end; ) {
|
|
585
|
+
const date = fromDays( day );
|
|
586
|
+
const weekend = date.getDay( )==0 || date.getDay( )==6;
|
|
587
|
+
|
|
588
|
+
switch( this._scale ) {
|
|
589
|
+
case "hour": {
|
|
590
|
+
for( let h = 0; h < 24; h++ ) {
|
|
591
|
+
cells.push( { from: day + h / 24, to: day + ( h + 1 ) / 24, text: ( "" + h ).padStart( 2, "0" ), weekend } );
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
day++;
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
case "day": {
|
|
599
|
+
cells.push( { from: day, to: day + 1, text: "" + date.getDate( ), weekend } );
|
|
600
|
+
day++;
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
case "week": {
|
|
605
|
+
const next = Math.min( Math.floor( toDays( date_week_start( date ) ) ) + 7, this._end );
|
|
606
|
+
cells.push( { from: day, to: next, text: "" + date_calc_weeknum( date ) } );
|
|
607
|
+
day = next;
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
case "month": {
|
|
612
|
+
const next = toDays( new Date( date.getFullYear( ), date.getMonth( ) + 1, 1 ) );
|
|
613
|
+
cells.push( { from: day, to: next, text: date_format( date, { month: "short" } ) } );
|
|
614
|
+
day = next;
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return cells;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
private _makeCell( cell: GanttCell ) {
|
|
624
|
+
const el = new Component( { cls: "cell", style: this._place( cell.from, cell.to ), content: new Component( { tag: "span", content: cell.text } ) } );
|
|
625
|
+
el.setClass( "weekend", !!cell.weekend );
|
|
626
|
+
return el;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// the title of a column of the list, with its sizer
|
|
630
|
+
private _makeTitle( col: GanttColumn, index: number ) {
|
|
631
|
+
const sizer = new CSizer( "right" );
|
|
632
|
+
const cell = new Component( { cls: "cell " + ( col.align ?? "left" ), style: { width: `var(--gantt-col${index}-width)` }, content: [
|
|
633
|
+
new Component( { tag: "span", content: col.title ?? _tr.global.gantt[col.id as keyof typeof _tr.global.gantt] } ),
|
|
634
|
+
sizer,
|
|
635
|
+
] } );
|
|
636
|
+
|
|
637
|
+
// the sizer gives the cell a width: it follows its column again
|
|
638
|
+
sizer.on( "resize", ( ev ) => {
|
|
639
|
+
this._widths[index] = Math.max( 24, Math.round( ev.width ) );
|
|
640
|
+
cell.setStyleValue( "width", `var(--gantt-col${index}-width)` );
|
|
641
|
+
this._layout( );
|
|
642
|
+
} );
|
|
643
|
+
|
|
644
|
+
sizer.on( "stop", ( ) => this._onResize( ) );
|
|
645
|
+
return cell;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// the line of a task in the list: a cell by column
|
|
649
|
+
private _makeLine( row: GanttRow ) {
|
|
650
|
+
const line = new Component( { cls: "row", tooltip: row.task.tooltip, content: this._columns.map( ( col, i ) => this._makeField( col, i, row ) ) } );
|
|
651
|
+
|
|
652
|
+
this._classify( line, row );
|
|
653
|
+
line.setClass( "selected", row.task.id===this._selection );
|
|
654
|
+
line.setInternalData( "row", row );
|
|
655
|
+
return line;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
private _makeField( col: GanttColumn, index: number, row: GanttRow ) {
|
|
659
|
+
const cell = new Component( { cls: "cell " + ( col.align ?? "left" ), style: { width: `var(--gantt-col${index}-width)` } } );
|
|
660
|
+
|
|
661
|
+
if( col.id=="title" ) {
|
|
662
|
+
// the tree: the level by the margin, the arrow of a group
|
|
663
|
+
cell.addClass( "title" );
|
|
664
|
+
cell.setStyle( { paddingLeft: row.level * 16 + 2 } );
|
|
665
|
+
cell.setContent( [
|
|
666
|
+
new Icon( { cls: "arrow", iconId: isGroup( row.task ) ? icons.chevron : undefined } ),
|
|
667
|
+
new Component( { tag: "span", cls: "text", content: this._fieldText( col, row ) } ),
|
|
668
|
+
] );
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
cell.setContent( this._fieldText( col, row ) );
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return cell;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private _fieldText( col: GanttColumn, row: GanttRow ): string | UnsafeHtml {
|
|
678
|
+
if( col.formatter ) {
|
|
679
|
+
return col.formatter( row.task );
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const task = row.task;
|
|
683
|
+
const timed = row.from!==undefined; // a task without start has no dates
|
|
684
|
+
|
|
685
|
+
switch( col.id ) {
|
|
686
|
+
case "title": return task.title;
|
|
687
|
+
case "start": return timed ? dateText( fromDays( row.from ) ) : "";
|
|
688
|
+
case "end": return timed ? dateText( toEnd( row.to ) ) : "";
|
|
689
|
+
case "duration": return timed && !task.milestone ? durationText( row.to - row.from ) : "";
|
|
690
|
+
case "progress": return timed && !task.milestone ? formatPercent( row.progress ) : "";
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const value = ( task as any )[col.id];
|
|
694
|
+
return value===undefined || value===null ? "" : "" + value;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// the classes of a task, on its line, its lane and its bar
|
|
698
|
+
private _classify( el: Component, row: GanttRow ) {
|
|
699
|
+
const task = row.task;
|
|
700
|
+
|
|
701
|
+
el.setClass( "group", isGroup( task ) );
|
|
702
|
+
el.setClass( "open", isGroup( task ) && task.open!==false );
|
|
703
|
+
el.setClass( "milestone", !!task.milestone );
|
|
704
|
+
|
|
705
|
+
if( task.cls ) {
|
|
706
|
+
el.addClass( task.cls );
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* behind the tasks, a path for each: the week-ends, the lines of the rows, a line by column,
|
|
712
|
+
* a line by unit of the first line, now over them. Drawn in a height of 1, that the svg
|
|
713
|
+
* stretches to the one of the chart
|
|
714
|
+
*/
|
|
715
|
+
|
|
716
|
+
private _renderGrid( top: GanttCell[], bottom: GanttCell[] ) {
|
|
717
|
+
const svg = new SvgBuilder( );
|
|
718
|
+
const weekends = svg.path( ).addClass( "weekend" );
|
|
719
|
+
svg.path( ).addClass( "row" ); // placed by _placeRows
|
|
720
|
+
const cols = svg.path( ).addClass( "col" );
|
|
721
|
+
const majors = svg.path( ).addClass( "major" );
|
|
722
|
+
|
|
723
|
+
bottom.forEach( cell => {
|
|
724
|
+
const x = this._x( cell.from );
|
|
725
|
+
cols.moveTo( x, 0 ).lineTo( x, 1 );
|
|
726
|
+
|
|
727
|
+
if( cell.weekend ) {
|
|
728
|
+
const x2 = this._x( cell.to );
|
|
729
|
+
weekends.moveTo( x, 0 ).lineTo( x2, 0 ).lineTo( x2, 1 ).lineTo( x, 1 ).closePath( );
|
|
730
|
+
}
|
|
731
|
+
} );
|
|
732
|
+
|
|
733
|
+
top.forEach( cell => {
|
|
734
|
+
const x = this._x( cell.from );
|
|
735
|
+
majors.moveTo( x, 0 ).lineTo( x, 1 );
|
|
736
|
+
} );
|
|
737
|
+
|
|
738
|
+
const now = toDays( new Date( ) );
|
|
739
|
+
if( now >= this._origin && now < this._end ) {
|
|
740
|
+
const x = this._x( now );
|
|
741
|
+
svg.path( ).addClass( "today" ).moveTo( x, 0 ).lineTo( x, 1 );
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
this._grid.setAttribute( "viewBox", `0 0 ${this._x( this._end )} 1` );
|
|
745
|
+
this._grid.setSvg( svg );
|
|
746
|
+
this._placeRows( );
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* the lines under the rows, in the height of 1 of the grid: on the last pixel of each row, from
|
|
751
|
+
* the height of the chart, which can be more than the rows (again when it changes)
|
|
752
|
+
*/
|
|
753
|
+
|
|
754
|
+
private _placeRows( ) {
|
|
755
|
+
const height = this._chart.dom.clientHeight;
|
|
756
|
+
const path = this._grid.dom.querySelector( ".row" );
|
|
757
|
+
if( !height || !path ) {
|
|
758
|
+
return; // not shown yet: the resize will come
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const width = this._x( this._end );
|
|
762
|
+
const d = this._rows.map( ( row, i ) => `M0 ${( ( i + 1 ) * this._rowHeight - 0.5 ) / height} H${width}` );
|
|
763
|
+
path.setAttribute( "d", d.join( " " ) );
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// the lane of a task in the chart, with its bar
|
|
767
|
+
private _makeLane( row: GanttRow ) {
|
|
768
|
+
const lane = new Component( { cls: "lane" } );
|
|
769
|
+
|
|
770
|
+
this._classify( lane, row );
|
|
771
|
+
lane.setClass( "selected", row.task.id===this._selection );
|
|
772
|
+
lane.setInternalData( "row", row );
|
|
773
|
+
|
|
774
|
+
if( row.from===undefined ) {
|
|
775
|
+
return lane;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const task = row.task;
|
|
779
|
+
const content = [
|
|
780
|
+
row.fill = new Component( { cls: "fill" } ),
|
|
781
|
+
new Component( { cls: "label", content: task.title } ),
|
|
782
|
+
];
|
|
783
|
+
|
|
784
|
+
// a group follows its tasks, a milestone is only moved
|
|
785
|
+
if( !this._readonly && !isGroup( task ) && !task.milestone ) {
|
|
786
|
+
row.fill.setContent( new Component( { cls: "handle", attrs: { "data-mode": "progress" } } ) );
|
|
787
|
+
content.push(
|
|
788
|
+
new Component( { cls: "handle", attrs: { "data-mode": "start" } } ),
|
|
789
|
+
new Component( { cls: "handle", attrs: { "data-mode": "end" } } ),
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
row.bar = new Component( { cls: "bar", tooltip: task.tooltip, content } );
|
|
794
|
+
this._classify( row.bar, row );
|
|
795
|
+
row.bar.setInternalData( "row", row );
|
|
796
|
+
|
|
797
|
+
// the stylesheet puts it on the bar
|
|
798
|
+
if( row.color ) {
|
|
799
|
+
row.bar.addClass( "colored" );
|
|
800
|
+
row.bar.setStyleVariable( "--gantt-task-color", row.color );
|
|
801
|
+
}
|
|
802
|
+
this._placeBar( row );
|
|
803
|
+
|
|
804
|
+
lane.setContent( row.bar );
|
|
805
|
+
return lane;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// the bar on its days; a milestone in their middle
|
|
809
|
+
private _placeBar( row: GanttRow ) {
|
|
810
|
+
const left = this._x( row.from );
|
|
811
|
+
const width = ( row.to - row.from ) * this._dw;
|
|
812
|
+
|
|
813
|
+
if( row.task.milestone ) {
|
|
814
|
+
row.bar.setStyle( { left: left + width / 2 } );
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
row.bar.setStyle( { left, width: Math.max( width, 2 ) } );
|
|
818
|
+
row.fill.setStyleValue( "width", clamp( row.progress, 0, 100 ) + "%" );
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* an arrow from the end of each task a task waits for to its start; around, back between the
|
|
824
|
+
* lines, when it starts before
|
|
825
|
+
*/
|
|
826
|
+
|
|
827
|
+
private _drawLinks( ) {
|
|
828
|
+
const svg = new SvgBuilder( );
|
|
829
|
+
const rh = this._rowHeight;
|
|
830
|
+
|
|
831
|
+
this._rows.forEach( dst => {
|
|
832
|
+
if( !dst.task.deps || dst.from===undefined ) {
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
dst.task.deps.forEach( id => {
|
|
837
|
+
const src = this._byId.get( id );
|
|
838
|
+
if( !src || src.from===undefined ) {
|
|
839
|
+
return; // in a group closed, or without dates
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const x1 = this._linkX( src, true );
|
|
843
|
+
const y1 = ( src.index + 0.5 ) * rh;
|
|
844
|
+
const x2 = this._linkX( dst, false );
|
|
845
|
+
const y2 = ( dst.index + 0.5 ) * rh;
|
|
846
|
+
|
|
847
|
+
const path = svg.path( ).moveTo( x1, y1 ).lineTo( x1 + GAP, y1 );
|
|
848
|
+
|
|
849
|
+
if( x2 - x1 >= 2 * GAP ) {
|
|
850
|
+
path.lineTo( x1 + GAP, y2 );
|
|
851
|
+
}
|
|
852
|
+
else {
|
|
853
|
+
const y = dst.index > src.index ? ( src.index + 1 ) * rh : src.index * rh;
|
|
854
|
+
path.lineTo( x1 + GAP, y ).lineTo( x2 - GAP, y ).lineTo( x2 - GAP, y2 );
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// the head
|
|
858
|
+
path.lineTo( x2, y2 ).moveTo( x2 - 5, y2 - 4 ).lineTo( x2, y2 ).lineTo( x2 - 5, y2 + 4 );
|
|
859
|
+
path.addClass( "link" );
|
|
860
|
+
|
|
861
|
+
if( src.task.id===this._selection || dst.task.id===this._selection ) {
|
|
862
|
+
path.addClass( "selected" );
|
|
863
|
+
}
|
|
864
|
+
} );
|
|
865
|
+
} );
|
|
866
|
+
|
|
867
|
+
this._links.setSvg( svg );
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// where an arrow leaves a task (its end) or comes to it (its start)
|
|
871
|
+
private _linkX( row: GanttRow, out: boolean ) {
|
|
872
|
+
if( row.task.milestone ) {
|
|
873
|
+
return this._x( ( row.from + row.to ) / 2 ) + ( out ? DIAMOND : -DIAMOND );
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
return this._x( out ? row.to : row.from );
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// the widths: the columns of the list, the list, the chart
|
|
880
|
+
private _layout( ) {
|
|
881
|
+
this._widths.forEach( ( width, i ) => {
|
|
882
|
+
this.setStyleVariable( `--gantt-col${i}-width`, width + "px" );
|
|
883
|
+
} );
|
|
884
|
+
|
|
885
|
+
const list = this._listWidth( );
|
|
886
|
+
this.setStyleVariable( "--gantt-list-width", list + "px" );
|
|
887
|
+
this._body.setStyleValue( "gridTemplateColumns", `${list}px ${this._x( this._end )}px` );
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
private _listWidth( ) {
|
|
891
|
+
return this._widths.reduce( ( sum, width ) => sum + width, 0 );
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// a day in the chart, in px
|
|
895
|
+
private _x( days: number ) {
|
|
896
|
+
return ( days - this._origin ) * this._dw;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
private _place( from: number, to: number ) {
|
|
900
|
+
return { left: this._x( from ), width: ( to - from ) * this._dw };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// the days in view when a day is at x px from the left of the chart in view
|
|
904
|
+
private _viewAt( day: number, x: number ): GanttView {
|
|
905
|
+
const from = day - x / this._dw;
|
|
906
|
+
return { from, to: from + ( this._body.dom.clientWidth - this._listWidth( ) ) / this._dw };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// the days in view; none before the first render
|
|
910
|
+
private _view( ): GanttView {
|
|
911
|
+
if( this._origin===undefined ) {
|
|
912
|
+
return null;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
return this._viewAt( this._origin + this._body.dom.scrollLeft / this._dw, 0 );
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* a view wider: more days, to fill it
|
|
920
|
+
*/
|
|
921
|
+
|
|
922
|
+
private _onResize( ) {
|
|
923
|
+
if( !this._drag && this._calcRange( this._view( ) ).end!=this._end ) {
|
|
924
|
+
this._render( );
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
this._placeRows( ); // the height of the chart: the lines of the rows in the grid
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* ctrl+wheel on the chart or its header: the width of a day, the day under the pointer stays
|
|
933
|
+
* under it; when the first day of the chart would come after the left of the view, it stays
|
|
934
|
+
* at the left. On the list, the browser zooms the page
|
|
935
|
+
*/
|
|
936
|
+
|
|
937
|
+
private _onWheel( ev: WheelEvent ) {
|
|
938
|
+
if( !ev.ctrlKey || this._drag ) {
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const body = this._body.dom;
|
|
943
|
+
const x = ev.clientX - body.getBoundingClientRect( ).left - this._listWidth( ); // from the left of the chart in view
|
|
944
|
+
if( x < 0 ) {
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
ev.preventDefault( );
|
|
949
|
+
|
|
950
|
+
// a deltaY of 100: 25% more or less
|
|
951
|
+
const dw = clamp( this._dw * Math.exp( -ev.deltaY / 450 ), MIN_DW, MAX_DW );
|
|
952
|
+
if( dw==this._dw ) {
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// the day of the last notch while neither the pointer nor the scroll moved: not the one of
|
|
957
|
+
// the scroll, rounded to the pixel at each notch
|
|
958
|
+
let day = this._origin + ( body.scrollLeft + x ) / this._dw;
|
|
959
|
+
const last = this._zoom;
|
|
960
|
+
if( last && last.x==x && last.scroll==body.scrollLeft ) {
|
|
961
|
+
day = last.day;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const scale = scaleOf( dw );
|
|
965
|
+
const changed = scale!=this._scale;
|
|
966
|
+
|
|
967
|
+
this._scale = scale;
|
|
968
|
+
this._dw = dw;
|
|
969
|
+
this._render( this._viewAt( day, x ) );
|
|
970
|
+
this._zoom = { day, x, scroll: body.scrollLeft };
|
|
971
|
+
|
|
972
|
+
if( changed ) {
|
|
973
|
+
this.fire( "scaleChange", { scale } );
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// :: SELECTION :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
978
|
+
|
|
979
|
+
private _select( row: GanttRow, notify: boolean ) {
|
|
980
|
+
const id = row ? row.task.id : undefined;
|
|
981
|
+
if( id===this._selection ) {
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const old = this._byId.get( this._selection );
|
|
986
|
+
if( old ) {
|
|
987
|
+
old.line.removeClass( "selected" );
|
|
988
|
+
old.lane.removeClass( "selected" );
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
this._selection = id;
|
|
992
|
+
|
|
993
|
+
if( row ) {
|
|
994
|
+
row.line.addClass( "selected" );
|
|
995
|
+
row.lane.addClass( "selected" );
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
this._drawLinks( );
|
|
999
|
+
|
|
1000
|
+
if( notify ) {
|
|
1001
|
+
this.fire( "selectionChange", { selection: row ? [ id ] : [], empty: !row } );
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// the scroll that shows a line under the header
|
|
1006
|
+
private _ensureVisible( row: GanttRow ) {
|
|
1007
|
+
const body = this._body.dom;
|
|
1008
|
+
const head = this._head.getBoundingRect( ).height;
|
|
1009
|
+
const top = row.index * this._rowHeight;
|
|
1010
|
+
|
|
1011
|
+
if( top < body.scrollTop ) {
|
|
1012
|
+
body.scrollTop = top;
|
|
1013
|
+
}
|
|
1014
|
+
else if( head + top + this._rowHeight > body.scrollTop + body.clientHeight ) {
|
|
1015
|
+
body.scrollTop = head + top + this._rowHeight - body.clientHeight;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
private _toggle( row: GanttRow ) {
|
|
1020
|
+
row.task.open = row.task.open===false;
|
|
1021
|
+
this._render( );
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// the task of a line of the list or of a lane of the chart
|
|
1025
|
+
private _rowOf( ev: Event ): GanttRow {
|
|
1026
|
+
const el = Component.closest( ev.target as Element, ".row, .lane" );
|
|
1027
|
+
if( el && this.contains( el ) ) {
|
|
1028
|
+
return el.getInternalData<GanttRow>( "row" );
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
return null;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
private _onClick( ev: MouseEvent ) {
|
|
1035
|
+
const row = this._rowOf( ev );
|
|
1036
|
+
if( !row ) {
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if( isGroup( row.task ) && Component.closest( ev.target as Element, ".arrow" ) ) {
|
|
1041
|
+
this._toggle( row );
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
this._select( row, true );
|
|
1046
|
+
this.fire( "click", { context: row.task } );
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
private _onDblClick( ev: MouseEvent ) {
|
|
1050
|
+
const row = this._rowOf( ev );
|
|
1051
|
+
if( row && !Component.closest( ev.target as Element, ".arrow" ) ) {
|
|
1052
|
+
this.fire( "dblClick", { context: row.task } );
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
private _onContextMenu( ev: MouseEvent ) {
|
|
1057
|
+
ev.preventDefault( );
|
|
1058
|
+
|
|
1059
|
+
const row = this._rowOf( ev );
|
|
1060
|
+
if( row ) {
|
|
1061
|
+
this._select( row, true );
|
|
1062
|
+
this.fire( "contextMenu", { uievent: ev, context: row.task } );
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* up, down, home, end: the task selected; right opens a group, left closes it, or goes back to
|
|
1068
|
+
* the group of the task
|
|
1069
|
+
*/
|
|
1070
|
+
|
|
1071
|
+
private _onKey( ev: KeyboardEvent ) {
|
|
1072
|
+
const rows = this._rows;
|
|
1073
|
+
const cur = this._byId.get( this._selection );
|
|
1074
|
+
let next: GanttRow;
|
|
1075
|
+
|
|
1076
|
+
switch( ev.key ) {
|
|
1077
|
+
case "ArrowDown": next = rows[cur ? cur.index + 1 : 0]; break;
|
|
1078
|
+
case "ArrowUp": next = rows[cur ? cur.index - 1 : 0]; break;
|
|
1079
|
+
case "Home": next = rows[0]; break;
|
|
1080
|
+
case "End": next = rows[rows.length - 1]; break;
|
|
1081
|
+
|
|
1082
|
+
case "ArrowRight": {
|
|
1083
|
+
if( cur && isGroup( cur.task ) && cur.task.open===false ) {
|
|
1084
|
+
this._toggle( cur );
|
|
1085
|
+
}
|
|
1086
|
+
break;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
case "ArrowLeft": {
|
|
1090
|
+
if( cur && isGroup( cur.task ) && cur.task.open!==false ) {
|
|
1091
|
+
this._toggle( cur );
|
|
1092
|
+
}
|
|
1093
|
+
else if( cur ) {
|
|
1094
|
+
next = rows.slice( 0, cur.index ).reverse( ).find( row => row.level < cur.level );
|
|
1095
|
+
}
|
|
1096
|
+
break;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
default: return;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
ev.preventDefault( );
|
|
1103
|
+
|
|
1104
|
+
if( next ) {
|
|
1105
|
+
this._select( next, true );
|
|
1106
|
+
this._ensureVisible( next );
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// :: DRAG ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* a bar under the pointer: moved, stretched by an end, its progress by the mark under it.
|
|
1114
|
+
* Nothing is taken before the pointer went 3px away: a click stays a click
|
|
1115
|
+
*/
|
|
1116
|
+
|
|
1117
|
+
private _onPointerDown( ev: PointerEvent ) {
|
|
1118
|
+
if( ev.button!=0 || this._readonly ) {
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
const bar = Component.closest( ev.target as Element, ".bar" );
|
|
1123
|
+
if( !bar || !this.contains( bar ) || bar.hasClass( "group" ) ) {
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
let mode: GanttDrag["mode"] = "move";
|
|
1128
|
+
const handle = Component.closest( ev.target as Element, ".handle" );
|
|
1129
|
+
if( handle ) {
|
|
1130
|
+
mode = handle.getData( "mode" ) as GanttDrag["mode"];
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
const row = bar.getInternalData<GanttRow>( "row" );
|
|
1134
|
+
this._drag = { row, mode, x: ev.clientX, from: row.from, to: row.to, progress: row.progress, moved: false };
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
private _onPointerMove( ev: PointerEvent ) {
|
|
1138
|
+
const drag = this._drag;
|
|
1139
|
+
if( !drag ) {
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const dx = ev.clientX - drag.x;
|
|
1144
|
+
|
|
1145
|
+
if( !drag.moved ) {
|
|
1146
|
+
// the button let go out of the chart, before any capture: no drag
|
|
1147
|
+
if( !( ev.buttons & 1 ) ) {
|
|
1148
|
+
this._drag = null;
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
if( Math.abs( dx ) < 3 ) {
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
drag.moved = true;
|
|
1157
|
+
this._chart.setCapture( ev.pointerId );
|
|
1158
|
+
this.addClass( drag.mode=="move" ? "moving" : "sizing" );
|
|
1159
|
+
this._select( drag.row, true );
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// by the hour, else by the day
|
|
1163
|
+
const steps = this._scale=="hour" ? 24 : 1;
|
|
1164
|
+
const snap = ( days: number ) => Math.round( days * steps ) / steps;
|
|
1165
|
+
const days = dx / this._dw;
|
|
1166
|
+
const row = drag.row;
|
|
1167
|
+
|
|
1168
|
+
switch( drag.mode ) {
|
|
1169
|
+
case "move": {
|
|
1170
|
+
const delta = snap( days );
|
|
1171
|
+
row.from = drag.from + delta;
|
|
1172
|
+
row.to = drag.to + delta;
|
|
1173
|
+
break;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
case "start": {
|
|
1177
|
+
row.from = Math.min( snap( drag.from + days ), drag.to - 1 / steps );
|
|
1178
|
+
break;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
case "end": {
|
|
1182
|
+
row.to = Math.max( snap( drag.to + days ), drag.from + 1 / steps );
|
|
1183
|
+
break;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
case "progress": {
|
|
1187
|
+
const width = ( drag.to - drag.from ) * this._dw;
|
|
1188
|
+
row.progress = Math.round( clamp( drag.progress + dx / width * 100, 0, 100 ) );
|
|
1189
|
+
break;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
this._placeBar( row );
|
|
1194
|
+
this._drawLinks( );
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* the change is told before it is written: ev.preventDefault( ) puts the bar back
|
|
1199
|
+
*/
|
|
1200
|
+
|
|
1201
|
+
private _onPointerUp( ev: PointerEvent ) {
|
|
1202
|
+
const drag = this._drag;
|
|
1203
|
+
this._drag = null;
|
|
1204
|
+
|
|
1205
|
+
if( !drag || !drag.moved ) {
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
this._chart.releaseCapture( ev.pointerId );
|
|
1210
|
+
this.removeClass( "moving sizing" );
|
|
1211
|
+
|
|
1212
|
+
const row = drag.row;
|
|
1213
|
+
const task = row.task;
|
|
1214
|
+
const change: EvGanttChange = { task, start: fromDays( row.from ), end: toEnd( row.to ), progress: row.progress };
|
|
1215
|
+
this.fire( "change", change );
|
|
1216
|
+
|
|
1217
|
+
if( change.defaultPrevented ) {
|
|
1218
|
+
this._restore( drag );
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
if( drag.mode=="progress" ) {
|
|
1223
|
+
task.progress = change.progress;
|
|
1224
|
+
}
|
|
1225
|
+
else {
|
|
1226
|
+
task.start = change.start;
|
|
1227
|
+
task.end = change.end;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
this._render( );
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// the browser took the pointer (a touch, a gesture) or the capture was lost: the bar goes back.
|
|
1234
|
+
// After a pointerup the drag is already over: the capture it releases changes nothing
|
|
1235
|
+
private _onPointerCancel( ) {
|
|
1236
|
+
const drag = this._drag;
|
|
1237
|
+
this._drag = null;
|
|
1238
|
+
|
|
1239
|
+
if( drag && drag.moved ) {
|
|
1240
|
+
this.removeClass( "moving sizing" );
|
|
1241
|
+
this._restore( drag );
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
private _restore( drag: GanttDrag ) {
|
|
1246
|
+
const row = drag.row;
|
|
1247
|
+
row.from = drag.from;
|
|
1248
|
+
row.to = drag.to;
|
|
1249
|
+
row.progress = drag.progress;
|
|
1250
|
+
|
|
1251
|
+
this._placeBar( row );
|
|
1252
|
+
this._drawLinks( );
|
|
1253
|
+
}
|
|
1254
|
+
}
|