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.
Files changed (43) hide show
  1. package/README.md +1 -1
  2. package/aicontext.md +385 -21
  3. package/package.json +8 -3
  4. package/src/components/assets/icons.ts +2 -0
  5. package/src/components/bargraph/bargraph.module.scss +44 -0
  6. package/src/components/bargraph/bargraph.ts +362 -0
  7. package/src/components/base.scss +71 -1
  8. package/src/components/calendar/calendar.module.scss +6 -5
  9. package/src/components/calendar/calendar.ts +14 -8
  10. package/src/components/canvas/canvas.ts +5 -4
  11. package/src/components/carousel/carousel.module.scss +165 -0
  12. package/src/components/carousel/carousel.ts +423 -0
  13. package/src/components/chart/chart.module.scss +8 -0
  14. package/src/components/chart/chart.ts +170 -12
  15. package/src/components/combobox/combobox.module.scss +2 -1
  16. package/src/components/components.ts +3 -0
  17. package/src/components/dialog/dialog.module.scss +2 -0
  18. package/src/components/dialog/dialog.ts +1 -0
  19. package/src/components/gantt/gantt.module.scss +462 -0
  20. package/src/components/gantt/gantt.ts +1254 -0
  21. package/src/components/knob/knob.module.scss +113 -0
  22. package/src/components/knob/knob.ts +355 -0
  23. package/src/components/mapview/mapview.module.scss +177 -0
  24. package/src/components/mapview/mapview.ts +764 -0
  25. package/src/components/menu/menu.module.scss +4 -6
  26. package/src/components/menu/menu.ts +4 -2
  27. package/src/components/planner/planner.module.scss +688 -0
  28. package/src/components/planner/planner.ts +1792 -0
  29. package/src/components/popup/popup.ts +29 -3
  30. package/src/components/radio/radio.module.scss +4 -1
  31. package/src/components/shared.scss +2 -1
  32. package/src/components/slider/slider.module.scss +22 -20
  33. package/src/components/switch/switch.module.scss +68 -44
  34. package/src/components/switch/switch.ts +6 -3
  35. package/src/components/tag/tag.module.scss +4 -4
  36. package/src/components/textedit/textedit.module.scss +5 -2
  37. package/src/components/themes.scss +27 -6
  38. package/src/components/tooltips/tooltips.scss +8 -0
  39. package/src/components/tooltips/tooltips.ts +31 -0
  40. package/src/core/core_i18n.ts +65 -0
  41. package/src/core/core_tools.ts +44 -0
  42. package/src/x4doc.ts +3 -0
  43. 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
+ }