x4js 2.3.28 → 2.3.29

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 (32) hide show
  1. package/aicontext.md +380 -19
  2. package/package.json +7 -3
  3. package/src/components/bargraph/bargraph.module.scss +44 -0
  4. package/src/components/bargraph/bargraph.ts +362 -0
  5. package/src/components/base.scss +71 -1
  6. package/src/components/calendar/calendar.module.scss +6 -5
  7. package/src/components/calendar/calendar.ts +14 -8
  8. package/src/components/canvas/canvas.ts +5 -4
  9. package/src/components/carousel/carousel.module.scss +165 -0
  10. package/src/components/carousel/carousel.ts +423 -0
  11. package/src/components/chart/chart.module.scss +8 -0
  12. package/src/components/chart/chart.ts +170 -12
  13. package/src/components/combobox/combobox.module.scss +2 -1
  14. package/src/components/components.ts +3 -0
  15. package/src/components/gantt/gantt.module.scss +462 -0
  16. package/src/components/gantt/gantt.ts +1254 -0
  17. package/src/components/knob/knob.module.scss +113 -0
  18. package/src/components/knob/knob.ts +355 -0
  19. package/src/components/mapview/mapview.module.scss +177 -0
  20. package/src/components/mapview/mapview.ts +764 -0
  21. package/src/components/planner/planner.module.scss +688 -0
  22. package/src/components/planner/planner.ts +1792 -0
  23. package/src/components/radio/radio.module.scss +4 -1
  24. package/src/components/slider/slider.module.scss +22 -20
  25. package/src/components/switch/switch.module.scss +68 -44
  26. package/src/components/switch/switch.ts +6 -3
  27. package/src/components/tag/tag.module.scss +4 -4
  28. package/src/components/textedit/textedit.module.scss +5 -2
  29. package/src/components/themes.scss +19 -6
  30. package/src/core/core_i18n.ts +65 -0
  31. package/src/core/core_tools.ts +44 -0
  32. package/src/x4doc.ts +3 -0
@@ -0,0 +1,1792 @@
1
+ /**
2
+ * ___ ___ __
3
+ * \ \/ / / _
4
+ * \ / /_| |_
5
+ * / \____ _|
6
+ * /__/\__\ |_|
7
+ *
8
+ * @file planner.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 { isMac } from '../../core/core_shortcuts';
21
+ import { SvgBuilder, SvgComponent, SvgPath } from '../../core/core_svg';
22
+ import { clamp, date_add_days, date_clone, date_format, date_hash, date_week_start, formatIntlDate } from '../../core/core_tools';
23
+ import { Icon } from '../icon/icon';
24
+
25
+ import "./planner.module.scss";
26
+
27
+ export type PlannerID = string | number;
28
+ export type PlannerView = "day" | "week" | "month";
29
+
30
+ export interface PlannerEvent {
31
+ id: PlannerID;
32
+ title: string;
33
+ start: Date;
34
+ end?: Date; // a date without hours is a whole day: end is its last day, included; else the time it ends. none: the day of start, or an hour after it
35
+ text?: string; // under the title, on as many lines as it has room for
36
+ color?: string; // a CSS colour for its border, its icon and a tint of its background; none: the accent of the theme
37
+ icon?: string; // on the right of its title, in its colour (as Icon: an svg)
38
+ tooltip?: string; // as html (see initTooltips)
39
+ cls?: string; // on each part of it: in each day, in the band, in the month
40
+ data?: any;
41
+ }
42
+
43
+ export interface EvPlannerChange extends ComponentEvent {
44
+ readonly event: PlannerEvent; // as it was: the new dates are written in it after the event, unless ev.preventDefault( )
45
+ readonly start: Date;
46
+ readonly end: Date; // as PlannerEvent.end
47
+ }
48
+
49
+ export interface EvPlannerSelect extends ComponentEvent {
50
+ readonly start: Date; // as an event: whole days have no hours, end is the last one
51
+ readonly end: Date;
52
+ }
53
+
54
+ export interface EvPlannerView extends ComponentEvent {
55
+ readonly view: PlannerView;
56
+ readonly date: Date;
57
+ }
58
+
59
+ export interface PlannerEvents extends ComponentEvents {
60
+ click: EvClick; // context: the event
61
+ dblClick: EvDblClick; // context: the event
62
+ contextMenu: EvContextMenu; // context: the event
63
+ selectionChange: EvSelectionChange;
64
+ change: EvPlannerChange; // an event moved, or one of its ends
65
+ copy: EvPlannerChange; // an event copied (ctrl, option on a Mac): nothing is written, the application adds it
66
+ select: EvPlannerSelect; // a range of time or of days, to create an event
67
+ viewChange: EvPlannerView; // the planner took another view: a day of the month opened
68
+ }
69
+
70
+ export interface PlannerProps extends Omit<ComponentProps,"content"> {
71
+ events?: PlannerEvent[];
72
+ view?: PlannerView; // "week" by default
73
+ date?: Date; // a day shown; today by default
74
+ workDays?: number[]; // as Date.getDay( ): monday to friday by default
75
+ workHours?: [number, number]; // in minutes from midnight: 8:00 to 18:00 by default
76
+ showOffDays?: boolean; // the other days, narrower: true by default; false: the working days alone
77
+ showOffHours?: boolean; // the other hours, above and under the working ones, reached by the scroll: true by default; false: the working hours alone
78
+ band?: boolean; // the events of whole days in a band under the days: true by default; false: in the working hours of their days
79
+ showSubGrid?: boolean; // the lines of the steps between the hours: false by default
80
+ step?: number; // the precision of the pointer, in minutes: 15 by default
81
+ readonly?: boolean; // nothing is created, moved, stretched nor copied; the events are still clicked, the view moved
82
+ click?: EventCallback<EvClick>;
83
+ dblClick?: EventCallback<EvDblClick>;
84
+ contextMenu?: EventCallback<EvContextMenu>;
85
+ selectionChange?: EventCallback<EvSelectionChange>;
86
+ change?: EventCallback<EvPlannerChange>;
87
+ copy?: EventCallback<EvPlannerChange>;
88
+ select?: EventCallback<EvPlannerSelect>;
89
+ viewChange?: EventCallback<EvPlannerView>;
90
+ }
91
+
92
+ // an event on the planner: [start, end[, whole days from the midnight of the first one to the one after the last
93
+ interface PlannerItem {
94
+ event: PlannerEvent;
95
+ start: Date;
96
+ end: Date;
97
+ allDay: boolean;
98
+ }
99
+
100
+ // a day of the week, its place
101
+ interface PlannerColumn {
102
+ date: Date; // at midnight
103
+ work: boolean;
104
+ x: number;
105
+ w: number;
106
+ }
107
+
108
+ // the part of an event with hours on a day, in minutes from midnight
109
+ interface PlannerSegment {
110
+ item: PlannerItem;
111
+ from: number;
112
+ to: number;
113
+ first: boolean; // the day it starts
114
+ last: boolean; // the day it ends
115
+ }
116
+
117
+ // an event of whole days in the band: its columns, its line
118
+ interface PlannerBar {
119
+ item: PlannerItem;
120
+ c0: number;
121
+ c1: number;
122
+ lane: number;
123
+ }
124
+
125
+ // a day of the month
126
+ interface PlannerDay {
127
+ date: Date;
128
+ cell: Component;
129
+ list: Component;
130
+ more: Component;
131
+ }
132
+
133
+ // a point of the planner: the day, and the minutes in the canvas
134
+ interface PlannerPoint {
135
+ day: Date;
136
+ t: number;
137
+ }
138
+
139
+ interface PlannerDrag {
140
+ area: "canvas" | "band" | "month";
141
+ kind: "select" | "move" | "start" | "end";
142
+ pointer: number;
143
+ x: number; // where the pointer went down, in the window
144
+ y: number;
145
+ moved: boolean; // the pointer went 4px away: a drag, not a click
146
+ copy: boolean; // the key of the copy is down
147
+ item: PlannerItem; // move, start, end
148
+ block: Component; // the part of it under the pointer: stretched by one of its ends
149
+ blocks: Component[]; // all its parts: faded while it is moved, not copied
150
+ ghost: Component; // where it goes, while it is moved
151
+ day: Date; // the day under the pointer at the start
152
+ t0: number; // canvas: the minutes under the pointer at the start
153
+ preview: { start: Date, end: Date }; // the event where it goes
154
+ }
155
+
156
+ // the range selected, [start, end[: on a day of the canvas, or days of the band or of the month
157
+ interface PlannerRange {
158
+ area: PlannerDrag["area"];
159
+ start: Date;
160
+ end: Date;
161
+ }
162
+
163
+ const DAY = 86400000;
164
+ const DAY_MINUTES = 24 * 60;
165
+ const GUTTER = 52; // the hours, on the left
166
+ const MIN_STEP_PX = 10; // a step of the working hours, at least
167
+ const HOURS_MARGIN = 12; // shown above and under the working hours: their first and last hours can be read
168
+ const OFF_DAY_WEIGHT = 0.35; // the width of a day off in the week, a working day being 1
169
+ const OFF_DAY_WEIGHT_MONTH = 0.5;
170
+ const FREE_STRIP = 14; // on the right of a day: a press there selects, even over an event
171
+ const SMALL_EVENT = 40; // under this height, an event is on one line
172
+ const EVENT_GAP = 2; // between an event and the next one, from top to bottom
173
+ const GHOST_SHIFT = 16; // the ghost of an event alone in its day: not on the event alone in the other one
174
+ const BAND_LINE = 22; // a line of the band of the whole days
175
+ const MONTH_LINE = 20; // a line of a day of the month, its gap included (scss)
176
+ const SCROLL_EDGE = 24; // a drag that near the top or the bottom of the body scrolls it
177
+ const SCROLL_STEP = 8; // by so many px every 20ms
178
+
179
+ // the days of the week from monday, as Date.getDay( ) numbers them
180
+ const WEEK = [ 1, 2, 3, 4, 5, 6, 0 ];
181
+
182
+ function hasTime( date: Date ) {
183
+ return date.getHours( )!=0 || date.getMinutes( )!=0;
184
+ }
185
+
186
+ function dayOf( date: Date ) {
187
+ return new Date( date.getFullYear( ), date.getMonth( ), date.getDate( ) );
188
+ }
189
+
190
+ function minutesOf( date: Date ) {
191
+ return date.getHours( ) * 60 + date.getMinutes( );
192
+ }
193
+
194
+ // a time of a day; 24:00: the midnight after it
195
+ function atMinutes( day: Date, minutes: number ) {
196
+ return new Date( day.getFullYear( ), day.getMonth( ), day.getDate( ), 0, minutes );
197
+ }
198
+
199
+ // the days from a midnight to another one (a day of summer time counts as one)
200
+ function daysBetween( from: Date, to: Date ) {
201
+ return Math.round( ( to.getTime( ) - from.getTime( ) ) / DAY );
202
+ }
203
+
204
+ function timeText( date: Date ) {
205
+ return formatIntlDate( date, "h:I" );
206
+ }
207
+
208
+ /**
209
+ * an event on the planner: [start, end[
210
+ */
211
+
212
+ function measure( event: PlannerEvent ): PlannerItem {
213
+ const start = event.start;
214
+
215
+ if( !hasTime( start ) && ( !event.end || !hasTime( event.end ) ) ) {
216
+ const last = event.end && event.end.getTime( ) > start.getTime( ) ? event.end : start;
217
+ return { event, start: dayOf( start ), end: date_add_days( dayOf( last ), 1 ), allDay: true };
218
+ }
219
+
220
+ const end = event.end ?? new Date( start.getTime( ) + 3600000 );
221
+ return { event, start, end: end.getTime( ) > start.getTime( ) ? end : start, allDay: false };
222
+ }
223
+
224
+ /**
225
+ * the end of an event from [start, end[: the day before for whole days (their last one), else that time
226
+ */
227
+
228
+ function endOf( start: Date, end: Date ) {
229
+ return !hasTime( start ) && !hasTime( end ) ? date_add_days( end, -1 ) : end;
230
+ }
231
+
232
+ /**
233
+ * the lane of each segment in its group of segments that overlap, and the count of lanes of that
234
+ * group: they share the width of their day. segments: sorted by start
235
+ */
236
+
237
+ function layoutLanes( segments: PlannerSegment[] ) {
238
+ const result: { segment: PlannerSegment, lane: number, lanes: number }[] = [];
239
+
240
+ let group: typeof result = [];
241
+ let ends: number[] = [];
242
+ let groupEnd = -1;
243
+
244
+ const close = ( ) => {
245
+ group.forEach( r => r.lanes = ends.length );
246
+ result.push( ...group );
247
+ group = [];
248
+ ends = [];
249
+ };
250
+
251
+ segments.forEach( segment => {
252
+ if( segment.from >= groupEnd ) {
253
+ close( );
254
+ }
255
+
256
+ let lane = ends.findIndex( end => end <= segment.from );
257
+ if( lane < 0 ) {
258
+ lane = ends.length;
259
+ ends.push( segment.to );
260
+ }
261
+ else {
262
+ ends[lane] = segment.to;
263
+ }
264
+
265
+ group.push( { segment, lane, lanes: 0 } );
266
+ groupEnd = Math.max( groupEnd, segment.to );
267
+ } );
268
+
269
+ close( );
270
+ return result;
271
+ }
272
+
273
+ /**
274
+ * the first and the last index of the dates from a day to another, [from, to[; null: none of them
275
+ */
276
+
277
+ function indicesOf( dates: Date[], from: Date, to: Date ): [ number, number ] {
278
+ let first = -1;
279
+ let last = -1;
280
+
281
+ dates.forEach( ( date, i ) => {
282
+ if( date.getTime( ) >= from.getTime( ) && date.getTime( ) < to.getTime( ) ) {
283
+ if( first < 0 ) {
284
+ first = i;
285
+ }
286
+
287
+ last = i;
288
+ }
289
+ } );
290
+
291
+ return first < 0 ? null : [ first, last ];
292
+ }
293
+
294
+ // the key that makes a drag a copy: option on a Mac, where ctrl+click is the right click; ctrl elsewhere
295
+ function isCopyKey( ev: MouseEvent ) {
296
+ return isMac ? ev.altKey : ev.ctrlKey;
297
+ }
298
+
299
+ /**
300
+ * Not in `x4js`: import it from `"x4js/planner"`.
301
+ *
302
+ * Events on a calendar, by day, by week or by month.
303
+ *
304
+ * In a day or a week, the days are in columns, the hours from top to bottom: the working hours
305
+ * fill the height of the view, the others above and under them are reached by the scroll, or not
306
+ * shown (showOffHours); the days
307
+ * off are narrower, or not shown (showOffDays). The events of whole days are in a band under the
308
+ * days, or in the working hours of their days (band). The lines of the steps between the hours
309
+ * can be shown (showSubGrid).
310
+ *
311
+ * With the pointer: a drag on an empty place selects a range (`select`: the application creates
312
+ * the event); the strip on the right of a day, or shift, select over the events too. A drag moves
313
+ * an event, or one of its ends (`change`); ctrl (option on a Mac) copies it (`copy`). In the month,
314
+ * a double click on a day, or its "+n", opens its week.
315
+ *
316
+ * Read only (readonly, setReadOnly): nothing is created, moved, stretched nor copied. Disabled
317
+ * (disabled, enable( false )): nothing, faded as a Box (--box-disabled-opacity).
318
+ *
319
+ * A date without hours is a whole day: `end` is the last day of the event, included.
320
+ *
321
+ * @cssvar
322
+ * ```
323
+ * --planner-background
324
+ * --planner-border
325
+ * --planner-border-focus
326
+ * --planner-header-color
327
+ * --planner-day-color
328
+ * --planner-off-color
329
+ * --planner-today-background
330
+ * --planner-today-color
331
+ * --planner-today-column
332
+ * --planner-off-background
333
+ * --planner-line-color
334
+ * --planner-step-color
335
+ * --planner-hour-line-color
336
+ * --planner-hour-color
337
+ * --planner-hour-color-work
338
+ * --planner-edge-background
339
+ * --planner-edge-color
340
+ * --planner-edge-shadow
341
+ * --planner-edge-opacity
342
+ * --planner-now-color
343
+ * --planner-now-tag
344
+ * --planner-now-text
345
+ * --planner-event-background
346
+ * --planner-event-color
347
+ * --planner-event-tint
348
+ * --planner-event-radius
349
+ * --planner-event-title
350
+ * --planner-event-text
351
+ * --planner-event-selected
352
+ * --planner-range-background
353
+ * --planner-range-border
354
+ * --planner-out-color
355
+ * --planner-more-color
356
+ * ```
357
+ */
358
+
359
+ export class Planner extends Component<PlannerProps, PlannerEvents> {
360
+ static classPrefix = "x4";
361
+
362
+ private _events: PlannerEvent[];
363
+ private _items: PlannerItem[] = [];
364
+ private _view: PlannerView;
365
+ private _date: Date; // at midnight
366
+ private _workDays: number[];
367
+ private _workHours: [number, number];
368
+ private _offDays: boolean; // the days off shown
369
+ private _offHours: boolean; // the hours out of the working ones shown
370
+ private _bandShown: boolean; // the events of whole days in the band
371
+ private _readonly: boolean;
372
+ private _step: number;
373
+
374
+ private _columns: PlannerColumn[] = [];
375
+ private _monthDays: PlannerDay[] = [];
376
+ private _blocks: Component[] = []; // the events drawn: in the days, in the band, in the month
377
+ private _hourPx = 40; // the height of a working hour
378
+ private _scrolled = false; // the first layout shows the start of the working hours
379
+ private _selection: PlannerID;
380
+ private _drag: PlannerDrag;
381
+ private _range: PlannerRange;
382
+ private _scrollDir = 0; // a drag near the top (-1) or the bottom (1) of the body: it scrolls
383
+ private _scrollEv: PointerEvent; // the last move of that drag
384
+ private _parts: { col: PlannerColumn, top: number, bottom: number }[] = []; // the events in the days: where they are
385
+
386
+ private _head: Component;
387
+ private _band: Component;
388
+ private _bandRange: Component;
389
+ private _body: Component;
390
+ private _canvas: Component;
391
+ private _grid: SvgComponent;
392
+ private _hours: Component;
393
+ private _layer: Component;
394
+ private _marks: Component;
395
+ private _now: Component; // the line of now: on the days before today
396
+ private _nowToday: Component; // on today, paler
397
+ private _nowAfter: Component; // on the days after it
398
+ private _nowTime: Component;
399
+ private _edges: Component;
400
+ private _month: Component;
401
+ private _names: Component;
402
+ private _cells: Component;
403
+
404
+ constructor( props: PlannerProps ) {
405
+ super( props );
406
+
407
+ this._events = props.events ?? [];
408
+ this._view = props.view ?? "week";
409
+ this._date = dayOf( props.date ?? new Date( ) );
410
+ this._workDays = props.workDays ?? [ 1, 2, 3, 4, 5 ];
411
+ this._workHours = props.workHours ?? [ 8 * 60, 18 * 60 ];
412
+ this._offDays = props.showOffDays ?? true;
413
+ this._offHours = props.showOffHours ?? true;
414
+ this._bandShown = props.band ?? true;
415
+ this._step = props.step ?? 15;
416
+
417
+ this.setAttribute( "tabindex", 0 );
418
+ this._readonly = !!props.readonly;
419
+ this.setClass( "readonly", this._readonly );
420
+ this.setClass( "nosubgrid", !props.showSubGrid );
421
+
422
+ this._bandRange = new Component( { cls: "range" } );
423
+
424
+ this.setContent( [
425
+ this._head = new Component( { cls: "head" } ),
426
+ this._band = new Component( { cls: "band" } ),
427
+ this._body = new Component( { cls: "body", content:
428
+ this._canvas = new Component( { cls: "canvas", content: [
429
+ this._grid = new SvgComponent( { cls: "grid" } ),
430
+ this._hours = new Component( { cls: "hours" } ),
431
+ this._layer = new Component( { cls: "layer" } ),
432
+ this._marks = new Component( { cls: "marks" } ),
433
+ this._now = new Component( { cls: "now" } ),
434
+ this._nowToday = new Component( { cls: "now today" } ),
435
+ this._nowAfter = new Component( { cls: "now" } ),
436
+ this._nowTime = new Component( { cls: "now-time" } ),
437
+ this._edges = new Component( { cls: "edges" } ),
438
+ ] } ),
439
+ } ),
440
+ this._month = new Component( { cls: "month", content: [
441
+ this._names = new Component( { cls: "names" } ),
442
+ this._cells = new Component( { cls: "cells" } ),
443
+ ] } ),
444
+ ] );
445
+
446
+ this.mapPropEvents( props, "click", "dblClick", "contextMenu", "selectionChange", "change", "copy", "select", "viewChange" );
447
+
448
+ this.addDOMEvent( "dblclick", ( ev ) => this._onDblClick( ev ) );
449
+ this.addDOMEvent( "contextmenu", ( ev ) => this._onContextMenu( ev ) );
450
+ this.addDOMEvent( "keydown", ( ev ) => this._onKey( ev ) );
451
+ this.addDOMEvent( "resized", ( ) => this._onResize( ) );
452
+ this._body.addDOMEvent( "scroll", ( ) => this._renderEdges( ) );
453
+
454
+ this.addDOMEvent( "pointerdown", ( ev ) => this._onPointerDown( ev ) );
455
+ this.addDOMEvent( "pointermove", ( ev ) => this._onPointerMove( ev ) );
456
+ this.addDOMEvent( "pointerup", ( ev ) => this._onPointerUp( ev ) );
457
+ this.addDOMEvent( "pointercancel", ( ) => this._onPointerCancel( ) );
458
+ this.addDOMEvent( "lostpointercapture", ( ) => this._onPointerCancel( ) );
459
+
460
+ this.setInterval( "now", 60 * 1000, ( ) => this._placeNow( ) );
461
+
462
+ this._render( );
463
+ }
464
+
465
+ /**
466
+ * the event selected, when it is not in them any more: none
467
+ */
468
+
469
+ setEvents( events: PlannerEvent[] ) {
470
+ this._events = events;
471
+ this._range = null;
472
+
473
+ if( !events.some( event => event.id===this._selection ) ) {
474
+ this._selection = undefined;
475
+ }
476
+
477
+ this._render( );
478
+ }
479
+
480
+ setView( view: PlannerView ) {
481
+ this._view = view;
482
+ this._range = null;
483
+ this._render( );
484
+ }
485
+
486
+ getView( ): PlannerView {
487
+ return this._view;
488
+ }
489
+
490
+ /**
491
+ * nothing is created, moved, stretched nor copied; the events are still clicked, the view moved
492
+ */
493
+
494
+ setReadOnly( ro: boolean ) {
495
+ this._readonly = ro;
496
+ this.setClass( "readonly", ro );
497
+ this._renderItems( ); // their ends to drag
498
+ }
499
+
500
+ /**
501
+ * the events of whole days in a band under the days; false: in the working hours of their days
502
+ */
503
+
504
+ setBand( band: boolean ) {
505
+ this._bandShown = band;
506
+ this._range = null;
507
+ this._render( );
508
+ }
509
+
510
+ /**
511
+ * the days off shown, narrower; false: the working days alone
512
+ */
513
+
514
+ setShowOffDays( show: boolean ) {
515
+ this._offDays = show;
516
+ this._range = null;
517
+ this._render( );
518
+ }
519
+
520
+ /**
521
+ * the lines of the steps between the hours; false: the lines of the hours alone
522
+ */
523
+
524
+ setShowSubGrid( show: boolean ) {
525
+ this.setClass( "nosubgrid", !show );
526
+ }
527
+
528
+ /**
529
+ * the precision of the pointer, in minutes; its lines in the working hours, of 10px at least
530
+ */
531
+
532
+ setStep( step: number ) {
533
+ this._step = step;
534
+ this._range = null;
535
+ this._render( );
536
+ }
537
+
538
+ /**
539
+ * the hours out of the working ones shown, reached by the scroll; false: the working hours alone
540
+ */
541
+
542
+ setShowOffHours( show: boolean ) {
543
+ this._offHours = show;
544
+ this._scrolled = false;
545
+ this._range = null;
546
+ this._render( );
547
+ }
548
+
549
+ /**
550
+ * a day to show: its day, its week or its month
551
+ */
552
+
553
+ setDate( date: Date ) {
554
+ this._date = dayOf( date );
555
+ this._range = null;
556
+ this._render( );
557
+ }
558
+
559
+ getDate( ): Date {
560
+ return date_clone( this._date );
561
+ }
562
+
563
+ /**
564
+ * the day, the week or the month after
565
+ */
566
+
567
+ next( ) {
568
+ this._moveBy( 1 );
569
+ }
570
+
571
+ prev( ) {
572
+ this._moveBy( -1 );
573
+ }
574
+
575
+ /**
576
+ * the first and the last day shown; in the month, its days
577
+ */
578
+
579
+ getRange( ): { start: Date, end: Date } {
580
+ const date = this._date;
581
+
582
+ switch( this._view ) {
583
+ case "day": {
584
+ return { start: date_clone( date ), end: date_clone( date ) };
585
+ }
586
+
587
+ case "week": {
588
+ const start = date_week_start( date );
589
+ return { start, end: date_add_days( start, 6 ) };
590
+ }
591
+
592
+ default: {
593
+ return { start: new Date( date.getFullYear( ), date.getMonth( ), 1 ), end: new Date( date.getFullYear( ), date.getMonth( ) + 1, 0 ) };
594
+ }
595
+ }
596
+ }
597
+
598
+ /**
599
+ * an event selected, without selectionChange; null: none
600
+ */
601
+
602
+ selectEvent( id: PlannerID ) {
603
+ const item = this._items.find( item => item.event.id===id );
604
+ this._select( item ?? null, false );
605
+ }
606
+
607
+ getSelection( ): PlannerID {
608
+ return this._selection;
609
+ }
610
+
611
+ private _moveBy( n: number ) {
612
+ const date = this._date;
613
+
614
+ switch( this._view ) {
615
+ case "day": {
616
+ // the days off not shown: the working day before or after
617
+ let day = date_add_days( date, n );
618
+ while( !this._offDays && this._workDays.length && !this._isWorkDay( day ) ) {
619
+ day = date_add_days( day, n );
620
+ }
621
+
622
+ this.setDate( day );
623
+ break;
624
+ }
625
+
626
+ case "week": this.setDate( date_add_days( date, 7 * n ) ); break;
627
+ case "month": this.setDate( new Date( date.getFullYear( ), date.getMonth( ) + n, 1 ) ); break;
628
+ }
629
+ }
630
+
631
+ // :: RENDER ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
632
+
633
+ private _render( ) {
634
+ this._items = this._events.filter( event => !!event.start ).map( measure );
635
+ this.setClass( "month", this._view=="month" );
636
+ this.setClass( "day-view", this._view=="day" );
637
+
638
+ if( this._view=="month" ) {
639
+ this._renderMonth( );
640
+ }
641
+ else {
642
+ this._renderWeek( );
643
+ }
644
+ }
645
+
646
+ /**
647
+ * the days in columns (the days off too, or not), the hours from top to bottom: the working
648
+ * hours fill what remains of the height, the others as high (or not shown), a step never
649
+ * smaller than MIN_STEP_PX
650
+ */
651
+
652
+ private _renderWeek( ) {
653
+ const day = this._view=="day";
654
+ const first = day ? this._date : date_week_start( this._date );
655
+ let dates: Date[] = [];
656
+ for( let i = 0; i < ( day ? 1 : 7 ); i++ ) {
657
+ dates.push( date_add_days( first, i ) );
658
+ }
659
+
660
+ if( !day && !this._offDays && this._workDays.length ) {
661
+ dates = dates.filter( date => this._isWorkDay( date ) );
662
+ }
663
+
664
+ // the band first: its height is taken from the one of the body
665
+ this._band.show( this._bandShown );
666
+ if( this._bandShown ) {
667
+ this._setBandHeight( this._layoutBars( this._items, dates ) );
668
+ }
669
+
670
+ const height = this._body.dom.clientHeight;
671
+ if( !height ) {
672
+ return; // not shown yet: the resize will come
673
+ }
674
+
675
+ // the time at the top of the view: it stays there when the height of an hour changes (a resize)
676
+ const top = this._scrolled ? this._tOf( this._body.dom.scrollTop ) : 0;
677
+
678
+ // the working hours fill the height, but for a margin above and under them where their first
679
+ // and last hours can be read; the others, as high, above and under them: the scroll reaches them
680
+ const [ workStart, workEnd ] = this._workHours;
681
+ const margin = this._offHours ? HOURS_MARGIN * 2 : 0;
682
+ this._hourPx = Math.max( MIN_STEP_PX * 60 / this._step, ( height - 1 - margin ) / ( ( workEnd - workStart ) / 60 ) );
683
+
684
+ const total = Math.ceil( this._yOf( DAY_MINUTES ) );
685
+ this._canvas.setStyle( { height: total } );
686
+
687
+ // the width of the body without its scroll bar
688
+ const width = this._body.dom.clientWidth - GUTTER;
689
+ const weights = dates.map( date => dates.length > 1 && !this._isWorkDay( date ) ? OFF_DAY_WEIGHT : 1 );
690
+ const sum = weights.reduce( ( a, b ) => a + b, 0 );
691
+
692
+ let x = GUTTER;
693
+ this._columns = dates.map( ( date, i ) => {
694
+ const w = width * weights[i] / sum;
695
+ const col = { date, work: this._isWorkDay( date ), x, w };
696
+ x += w;
697
+ return col;
698
+ } );
699
+
700
+ this._renderHead( );
701
+ this._renderGrid( x, total );
702
+ this._renderHours( );
703
+ this._renderItems( );
704
+ this._placeNow( );
705
+
706
+ if( !this._scrolled ) {
707
+ this._scrolled = true;
708
+ this._body.dom.scrollTop = this._yOf( workStart ) - HOURS_MARGIN;
709
+ }
710
+ else {
711
+ this._body.dom.scrollTop = this._yOf( top );
712
+ }
713
+ }
714
+
715
+ // the days, over their columns: the name of the day, its number under it
716
+ private _renderHead( ) {
717
+ const today = date_hash( new Date( ) );
718
+
719
+ this._head.setContent( this._columns.map( col => {
720
+ const cell = new Component( { cls: "day", style: { left: col.x, width: col.w }, content: [
721
+ new Component( { cls: "name", content: date_format( col.date, { weekday: "short" } ) } ),
722
+ new Component( { cls: "num", content: "" + col.date.getDate( ) } ),
723
+ ] } );
724
+ cell.setClass( "off", !col.work );
725
+ cell.setClass( "today", date_hash( col.date )==today );
726
+ return cell;
727
+ } ) );
728
+ }
729
+
730
+ /**
731
+ * behind the events, a path for each: the days off and the hours out of the working ones,
732
+ * today, the steps, the lines between the days and of the hours off, the lines of the working hours
733
+ */
734
+
735
+ private _renderGrid( width: number, height: number ) {
736
+ const svg = new SvgBuilder( );
737
+ const off = svg.path( ).addClass( "off" );
738
+ const today = svg.path( ).addClass( "today" );
739
+ const steps = svg.path( ).addClass( "step" );
740
+ const lines = svg.path( ).addClass( "line" );
741
+ const hours = svg.path( ).addClass( "hour" );
742
+ const [ workStart, workEnd ] = this._workHours;
743
+ const [ shownStart, shownEnd ] = this._hoursShown( );
744
+
745
+ const rect = ( path: SvgPath, x1: number, y1: number, x2: number, y2: number ) => {
746
+ path.moveTo( x1, y1 ).lineTo( x2, y1 ).lineTo( x2, y2 ).lineTo( x1, y2 ).closePath( );
747
+ };
748
+
749
+ if( this._offHours ) {
750
+ rect( off, GUTTER, 0, width, this._yOf( workStart ) );
751
+ rect( off, GUTTER, this._yOf( workEnd ), width, height );
752
+ }
753
+
754
+ const now = date_hash( new Date( ) );
755
+ this._columns.forEach( col => {
756
+ if( !col.work ) {
757
+ rect( off, col.x, 0, col.x + col.w, height );
758
+ }
759
+
760
+ if( date_hash( col.date )==now ) {
761
+ rect( today, col.x, 0, col.x + col.w, height );
762
+ }
763
+
764
+ lines.moveTo( col.x, 0 ).lineTo( col.x, height );
765
+ } );
766
+
767
+ // the hours shown: darker in the working ones; the steps from midnight, in the working hours
768
+ for( let h = 1; h < 24; h++ ) {
769
+ const t = h * 60;
770
+ if( t <= shownStart || t >= shownEnd ) {
771
+ continue;
772
+ }
773
+
774
+ const y = this._yOf( t );
775
+ ( t >= workStart && t <= workEnd ? hours : lines ).moveTo( GUTTER, y ).lineTo( width, y );
776
+ }
777
+
778
+ for( let t = Math.ceil( workStart / this._step ) * this._step; t < workEnd; t += this._step ) {
779
+ if( t % 60 ) {
780
+ const y = this._yOf( t );
781
+ steps.moveTo( GUTTER, y ).lineTo( width, y );
782
+ }
783
+ }
784
+
785
+ this._grid.setAttribute( "width", width );
786
+ this._grid.setAttribute( "height", height );
787
+ this._grid.setAttribute( "viewBox", `0 0 ${width} ${height}` );
788
+ this._grid.setSvg( svg );
789
+ }
790
+
791
+ // the hours shown on the left, but on the edges; those out of the working ones paler
792
+ private _renderHours( ) {
793
+ const [ workStart, workEnd ] = this._workHours;
794
+ const [ shownStart, shownEnd ] = this._hoursShown( );
795
+ const labels: Component[] = [];
796
+
797
+ for( let h = 1; h < 24; h++ ) {
798
+ const t = h * 60;
799
+ const work = t >= workStart && t <= workEnd;
800
+ if( t <= shownStart || t >= shownEnd ) {
801
+ continue;
802
+ }
803
+
804
+ const label = new Component( { cls: "hour", style: { top: this._yOf( t ) }, content: timeText( atMinutes( this._date, t ) ) } );
805
+ label.setClass( "work", work );
806
+ label.setInternalData( "y", this._yOf( t ) );
807
+ labels.push( label );
808
+ }
809
+
810
+ this._hours.setContent( labels );
811
+ }
812
+
813
+ // the events: in the band and in the days, or in the month
814
+ private _renderItems( ) {
815
+ this._blocks = [];
816
+
817
+ if( this._view=="month" ) {
818
+ this._renderLines( );
819
+ }
820
+ else {
821
+ if( this._bandShown ) {
822
+ this._renderBand( );
823
+ }
824
+
825
+ this._renderEvents( );
826
+ }
827
+
828
+ this._renderRange( );
829
+ }
830
+
831
+ /**
832
+ * the events of whole days on the days shown: on the first line of the band where they have room
833
+ */
834
+
835
+ private _layoutBars( items: PlannerItem[], dates: Date[] ): PlannerBar[] {
836
+ const bars: PlannerBar[] = [];
837
+ const ends: number[] = []; // the last column of each line
838
+
839
+ items
840
+ .filter( item => item.allDay )
841
+ .sort( ( a, b ) => a.start.getTime( ) - b.start.getTime( ) || b.end.getTime( ) - a.end.getTime( ) )
842
+ .forEach( item => {
843
+ // on the days shown only
844
+ const cols = indicesOf( dates, item.start, item.end );
845
+ if( !cols ) {
846
+ return;
847
+ }
848
+
849
+ const [ c0, c1 ] = cols;
850
+ let lane = ends.findIndex( end => end < c0 );
851
+ if( lane < 0 ) {
852
+ lane = ends.length;
853
+ ends.push( c1 );
854
+ }
855
+ else {
856
+ ends[lane] = c1;
857
+ }
858
+
859
+ bars.push( { item, c0, c1, lane } );
860
+ } );
861
+
862
+ return bars;
863
+ }
864
+
865
+ // a line for each line of bars, one at least: a range of days can be selected in it
866
+ private _setBandHeight( bars: PlannerBar[] ) {
867
+ const lanes = bars.reduce( ( n, bar ) => Math.max( n, bar.lane + 1 ), 1 );
868
+ this._band.setStyle( { height: lanes * BAND_LINE + 4 } );
869
+ }
870
+
871
+ // the band of the whole days: the days off, the bars, the range of days selected
872
+ private _renderBand( ) {
873
+ const cols = this._columns;
874
+ const bars = this._layoutBars( this._items, cols.map( col => col.date ) );
875
+ this._setBandHeight( bars );
876
+
877
+ const today = date_hash( new Date( ) );
878
+ const content: Component[] = cols.map( col => {
879
+ const day = new Component( { cls: "day", style: { left: col.x, width: col.w } } );
880
+ day.setClass( "off", !col.work );
881
+ day.setClass( "today", date_hash( col.date )==today );
882
+ return day;
883
+ } );
884
+
885
+ bars.forEach( bar => {
886
+ const x = cols[bar.c0].x + 2;
887
+ const block = this._makeBar( bar.item );
888
+ block.setStyle( { left: x, width: cols[bar.c1].x + cols[bar.c1].w - x - 3, top: bar.lane * BAND_LINE + 2, height: BAND_LINE - 2 } );
889
+ content.push( block );
890
+ } );
891
+
892
+ content.push( this._bandRange );
893
+ this._band.setContent( content );
894
+ }
895
+
896
+ /**
897
+ * the events in their days (those of whole days too, without the band); those that overlap
898
+ * share the width of their day, but for the strip on its right
899
+ */
900
+
901
+ private _renderEvents( ) {
902
+ const items = this._bandShown ? this._items.filter( item => !item.allDay ) : this._items;
903
+ const blocks: Component[] = [];
904
+ this._parts = [];
905
+
906
+ this._columns.forEach( col => {
907
+ const segments = items.map( item => this._segmentOn( item, col.date ) ).filter( segment => !!segment );
908
+ segments.sort( ( a, b ) => a.from - b.from || a.to - b.to );
909
+
910
+ const free = Math.min( FREE_STRIP, col.w * 0.2 );
911
+ layoutLanes( segments ).forEach( ( { segment, lane, lanes } ) => {
912
+ const w = ( col.w - 4 - free ) / lanes;
913
+ const span = this._span( segment.from, segment.to );
914
+
915
+ const block = this._makeEvent( segment );
916
+ block.setStyle( { left: col.x + 2 + lane * w, width: w - 2, ...span } );
917
+ block.setClass( "small", span.height < SMALL_EVENT );
918
+ block.setInternalData( "segment", segment );
919
+ block.setInternalData( "alone", lanes==1 );
920
+ blocks.push( block );
921
+ this._parts.push( { col, top: span.top, bottom: span.top + span.height } );
922
+ } );
923
+ } );
924
+
925
+ this._layer.setContent( blocks );
926
+ this._renderEdges( );
927
+ }
928
+
929
+ /**
930
+ * the events out of the view, above or under it: in each day, a mark at the top or the bottom
931
+ * of the view, how many; a click on it scrolls to the nearest of them
932
+ */
933
+
934
+ private _renderEdges( ) {
935
+ const body = this._body.dom;
936
+ const top = body.scrollTop;
937
+ const bottom = top + body.clientHeight;
938
+ const edges: Component[] = [];
939
+
940
+ this._columns.forEach( col => {
941
+ const parts = this._parts.filter( part => part.col===col );
942
+ const above = parts.filter( part => part.bottom <= top );
943
+ const under = parts.filter( part => part.top >= bottom );
944
+
945
+ if( above.length ) {
946
+ const nearest = above.reduce( ( a, b ) => b.bottom > a.bottom ? b : a );
947
+ edges.push( this._makeEdge( col, "up", above.length, top, ( ) => body.scrollTop = nearest.top - HOURS_MARGIN ) );
948
+ }
949
+
950
+ if( under.length ) {
951
+ const nearest = under.reduce( ( a, b ) => b.top < a.top ? b : a );
952
+ edges.push( this._makeEdge( col, "down", under.length, bottom, ( ) => body.scrollTop = nearest.bottom - body.clientHeight + HOURS_MARGIN ) );
953
+ }
954
+ } );
955
+
956
+ this._edges.setContent( edges );
957
+ }
958
+
959
+ // a mark of the events out of the view in a day, at y: the top of the view, or its bottom
960
+ private _makeEdge( col: PlannerColumn, dir: "up" | "down", count: number, y: number, click: ( ) => void ) {
961
+ return new Component( {
962
+ cls: "edge " + dir,
963
+ style: { left: col.x + col.w / 2, top: y },
964
+ content: ( dir=="up" ? "▲ " : "▼ " ) + count,
965
+ dom_events: { click },
966
+ } );
967
+ }
968
+
969
+ /**
970
+ * now, when today is shown, in the hours shown: a line across the days, over their events,
971
+ * paler across today; its time in the hours on the left, over the one it would cover
972
+ */
973
+
974
+ private _placeNow( ) {
975
+ const now = new Date( );
976
+ const [ shownStart, shownEnd ] = this._hoursShown( );
977
+ const t = minutesOf( now );
978
+ const col = this._view=="month" || t < shownStart || t > shownEnd ? null : this._columnOf( now );
979
+ const shown = !!col;
980
+ const top = this._yOf( t );
981
+ const first = this._columns[0];
982
+ const last = this._columns[this._columns.length - 1];
983
+
984
+ this._now.show( shown && col!==first );
985
+ this._nowToday.show( shown );
986
+ this._nowAfter.show( shown && col!==last );
987
+ this._nowTime.show( shown );
988
+
989
+ if( shown ) {
990
+ this._now.setStyle( { left: first.x, width: col.x - first.x, top } );
991
+ this._nowToday.setStyle( { left: col.x, width: col.w, top } );
992
+ this._nowAfter.setStyle( { left: col.x + col.w, width: last.x + last.w - col.x - col.w, top } );
993
+ this._nowTime.setStyle( { top } );
994
+ this._nowTime.setContent( timeText( now ) );
995
+ }
996
+
997
+ this._hours.enumChildComponents( false ).forEach( label => {
998
+ label.setClass( "covered", shown && Math.abs( label.getInternalData<number>( "y" ) - top ) < 14 );
999
+ } );
1000
+ }
1001
+
1002
+ /**
1003
+ * the month of the date: its weeks from the monday before its first day to the sunday after
1004
+ * its last one, the days off narrower (or not shown)
1005
+ */
1006
+
1007
+ private _renderMonth( ) {
1008
+ const month = new Date( this._date.getFullYear( ), this._date.getMonth( ), 1 );
1009
+ const first = date_week_start( month );
1010
+ const last = new Date( month.getFullYear( ), month.getMonth( ) + 1, 0 );
1011
+ const weeks = Math.ceil( ( daysBetween( first, last ) + 1 ) / 7 );
1012
+ const today = date_hash( new Date( ) );
1013
+ const shown = ( d: number ) => this._offDays || !this._workDays.length || this._workDays.includes( d );
1014
+ const week = WEEK.filter( shown );
1015
+
1016
+ this._month.setStyleVariable( "--planner-cols", week.map( d => this._workDays.includes( d ) ? "1fr" : OFF_DAY_WEIGHT_MONTH + "fr" ).join( " " ) );
1017
+ this._month.setStyleVariable( "--planner-rows", "" + weeks );
1018
+
1019
+ this._names.setContent( week.map( d => {
1020
+ const name = new Component( { cls: "name", content: _tr.global.day_short[d] } );
1021
+ name.setClass( "off", !this._workDays.includes( d ) );
1022
+ return name;
1023
+ } ) );
1024
+
1025
+ this._monthDays = [];
1026
+ for( let i = 0; i < weeks * 7; i++ ) {
1027
+ const date = date_add_days( first, i );
1028
+ if( !shown( date.getDay( ) ) ) {
1029
+ continue;
1030
+ }
1031
+
1032
+ const list = new Component( { cls: "list" } );
1033
+ const more = new Component( { cls: "more", hidden: true } );
1034
+ const cell = new Component( { cls: "day", content: [ new Component( { cls: "num", content: "" + date.getDate( ) } ), list, more ] } );
1035
+
1036
+ cell.setClass( "out", date.getMonth( )!=month.getMonth( ) );
1037
+ cell.setClass( "today", date_hash( date )==today );
1038
+ cell.setClass( "off", !this._isWorkDay( date ) );
1039
+ this._monthDays.push( { date, cell, list, more } );
1040
+ }
1041
+
1042
+ this._cells.setContent( this._monthDays.map( day => day.cell ) );
1043
+ this._renderItems( );
1044
+ }
1045
+
1046
+ /**
1047
+ * the events of each day of the month, the whole days first; when they do not hold in it,
1048
+ * the last line tells how many are not shown
1049
+ */
1050
+
1051
+ private _renderLines( ) {
1052
+ const items = this._items;
1053
+
1054
+ this._monthDays.forEach( day => {
1055
+ const from = day.date.getTime( );
1056
+ const next = date_add_days( day.date, 1 ).getTime( );
1057
+
1058
+ const list = items
1059
+ .filter( item => item.start.getTime( ) < next && ( item.end.getTime( ) > from || item.start.getTime( )==from ) )
1060
+ .sort( ( a, b ) => +b.allDay - +a.allDay || a.start.getTime( ) - b.start.getTime( ) || a.end.getTime( ) - b.end.getTime( ) );
1061
+
1062
+ // how many lines hold: all of them while the month is not shown
1063
+ const height = day.list.getBoundingRect( ).height + day.more.getBoundingRect( ).height;
1064
+ const room = height > 0 ? Math.max( 1, Math.floor( height / MONTH_LINE ) ) : list.length;
1065
+ const shown = list.length > room ? room - 1 : list.length;
1066
+
1067
+ day.list.setContent( list.slice( 0, shown ).map( item => this._makeLine( item, day.date ) ) );
1068
+ day.more.setContent( "+" + ( list.length - shown ) );
1069
+ day.more.show( list.length > shown );
1070
+ } );
1071
+ }
1072
+
1073
+ // the range selected: on a day of the canvas, on days of the band or of the month
1074
+ private _renderRange( ) {
1075
+ const range = this._range;
1076
+
1077
+ if( this._view=="month" ) {
1078
+ this._monthDays.forEach( day => {
1079
+ const date = day.date.getTime( );
1080
+ day.cell.setClass( "range", !!range && date >= range.start.getTime( ) && date < range.end.getTime( ) );
1081
+ } );
1082
+ return;
1083
+ }
1084
+
1085
+ const cols = this._columns;
1086
+ const marks: Component[] = [];
1087
+ this._bandRange.hide( );
1088
+
1089
+ if( range && range.area=="canvas" ) {
1090
+ const col = this._columnOf( range.start );
1091
+ if( col ) {
1092
+ const top = this._yOf( minutesOf( range.start ) );
1093
+
1094
+ marks.push(
1095
+ new Component( { cls: "range", style: { left: col.x + 1, width: col.w - 2, top, height: this._yOf( this._minutesTo( col.date, range.end ) ) - top } } ),
1096
+ new Component( { cls: "range-label", style: { left: col.x + 6, top: top + 2 }, content: `${timeText( range.start )} – ${timeText( range.end )}` } ),
1097
+ );
1098
+ }
1099
+ }
1100
+ else if( range && range.area=="band" ) {
1101
+ const shown = indicesOf( cols.map( col => col.date ), range.start, range.end );
1102
+ if( shown ) {
1103
+ const [ c0, c1 ] = shown;
1104
+ this._bandRange.setStyle( { left: cols[c0].x + 1, width: cols[c1].x + cols[c1].w - cols[c0].x - 2 } );
1105
+ this._bandRange.show( );
1106
+ }
1107
+ }
1108
+
1109
+ this._marks.setContent( marks );
1110
+ }
1111
+
1112
+ // :: EVENTS DRAWN ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
1113
+
1114
+ // what each part of an event has: its colour, its tooltip, its classes
1115
+ private _makeBlock( item: PlannerItem, cls: string, content: Component[] ) {
1116
+ const event = item.event;
1117
+ const block = new Component( { cls: "event " + cls, tooltip: event.tooltip, content } );
1118
+
1119
+ block.setClass( "selected", event.id===this._selection );
1120
+
1121
+ // the stylesheet puts it on its border, its texts and its tint
1122
+ if( event.color ) {
1123
+ block.addClass( "colored" );
1124
+ block.setStyleVariable( "--planner-color", event.color );
1125
+ }
1126
+
1127
+ if( event.cls ) {
1128
+ block.addClass( event.cls );
1129
+ }
1130
+
1131
+ block.setInternalData( "item", item );
1132
+ this._blocks.push( block );
1133
+ return block;
1134
+ }
1135
+
1136
+ // the title of an event, its icon on its right
1137
+ private _makeHead( event: PlannerEvent ) {
1138
+ const content: Component[] = [ new Component( { cls: "title", content: event.title } ) ];
1139
+
1140
+ if( event.icon ) {
1141
+ content.push( new Icon( { cls: "icon", iconId: event.icon } ) );
1142
+ }
1143
+
1144
+ return new Component( { cls: "head", content } );
1145
+ }
1146
+
1147
+ // an event in a day: its title, its times (a whole day has none), its text; the ends that can be dragged, on the days it starts and ends
1148
+ private _makeEvent( segment: PlannerSegment ) {
1149
+ const item = segment.item;
1150
+ const event = item.event;
1151
+ const content: Component[] = [ this._makeHead( event ) ];
1152
+
1153
+ if( !item.allDay ) {
1154
+ content.push( new Component( { cls: "time", content: `${timeText( item.start )} – ${timeText( item.end )}` } ) );
1155
+ }
1156
+
1157
+ if( event.text ) {
1158
+ content.push( new Component( { cls: "text", content: event.text } ) );
1159
+ }
1160
+
1161
+ if( !this._readonly ) {
1162
+ if( segment.first ) {
1163
+ content.push( new Component( { cls: "grip start" } ) );
1164
+ }
1165
+
1166
+ if( segment.last ) {
1167
+ content.push( new Component( { cls: "grip end" } ) );
1168
+ }
1169
+ }
1170
+
1171
+ return this._makeBlock( item, "", content );
1172
+ }
1173
+
1174
+ // an event of whole days in the band: its title, its ends that can be dragged
1175
+ private _makeBar( item: PlannerItem ) {
1176
+ const content: Component[] = [ this._makeHead( item.event ) ];
1177
+
1178
+ if( !this._readonly ) {
1179
+ content.push( new Component( { cls: "grip start" } ), new Component( { cls: "grip end" } ) );
1180
+ }
1181
+
1182
+ return this._makeBlock( item, "bar", content );
1183
+ }
1184
+
1185
+ // an event in a day of the month: the time it starts that day, its title
1186
+ private _makeLine( item: PlannerItem, day: Date ) {
1187
+ const content: Component[] = [ this._makeHead( item.event ) ];
1188
+
1189
+ if( !item.allDay && item.start.getTime( ) >= day.getTime( ) ) {
1190
+ content.unshift( new Component( { cls: "time", content: timeText( item.start ) } ) );
1191
+ }
1192
+
1193
+ return this._makeBlock( item, "line", content );
1194
+ }
1195
+
1196
+ // :: GEOMETRY ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
1197
+
1198
+ private _isWorkDay( date: Date ) {
1199
+ return this._workDays.includes( date.getDay( ) );
1200
+ }
1201
+
1202
+ // the hours shown, in minutes from midnight: the whole day, or the working hours alone
1203
+ private _hoursShown( ): [ number, number ] {
1204
+ return this._offHours ? [ 0, DAY_MINUTES ] : this._workHours;
1205
+ }
1206
+
1207
+ // the column of the day of a date, null when it is not shown
1208
+ private _columnOf( date: Date ): PlannerColumn {
1209
+ const day = dayOf( date ).getTime( );
1210
+ return this._columns.find( col => col.date.getTime( )==day ) ?? null;
1211
+ }
1212
+
1213
+ /**
1214
+ * the part of an event on a day, in minutes from midnight; null: none, or out of the hours
1215
+ * shown. A whole day (without the band): the working hours, without ends to drag
1216
+ */
1217
+
1218
+ private _segmentOn( item: PlannerItem, day: Date ): PlannerSegment {
1219
+ const from = day.getTime( );
1220
+ const next = date_add_days( day, 1 ).getTime( );
1221
+ const start = item.start.getTime( );
1222
+ const end = item.end.getTime( );
1223
+
1224
+ if( start >= next || end < from || ( end==from && start < from ) ) {
1225
+ return null;
1226
+ }
1227
+
1228
+ if( item.allDay ) {
1229
+ const [ workStart, workEnd ] = this._workHours;
1230
+ return { item, from: workStart, to: workEnd, first: false, last: false };
1231
+ }
1232
+
1233
+ const segment = { item, from: start >= from ? minutesOf( item.start ) : 0, to: this._minutesTo( day, item.end ), first: start >= from, last: end <= next };
1234
+ const [ shownStart, shownEnd ] = this._hoursShown( );
1235
+ return segment.from >= shownEnd || segment.to <= shownStart ? null : segment;
1236
+ }
1237
+
1238
+ // a time of a day (minutes) in the canvas, kept in the hours shown
1239
+ private _yOf( t: number ) {
1240
+ const [ shownStart, shownEnd ] = this._hoursShown( );
1241
+ return ( clamp( t, shownStart, shownEnd ) - shownStart ) / 60 * this._hourPx;
1242
+ }
1243
+
1244
+ // the time at y in the canvas, in the hours shown
1245
+ private _tOf( y: number ) {
1246
+ const [ shownStart, shownEnd ] = this._hoursShown( );
1247
+ return clamp( shownStart + y / this._hourPx * 60, shownStart, shownEnd );
1248
+ }
1249
+
1250
+ private _snap( t: number ) {
1251
+ return Math.floor( t / this._step ) * this._step;
1252
+ }
1253
+
1254
+ private _snapRound( t: number ) {
1255
+ return Math.round( t / this._step ) * this._step;
1256
+ }
1257
+
1258
+ // a part of an event in the canvas, from a time of its day to another: on the line of its start,
1259
+ // the gap under it, 6px high at least, never past the bottom
1260
+ private _span( from: number, to: number ) {
1261
+ const top = this._yOf( from );
1262
+ const height = Math.max( this._yOf( to ) - top - EVENT_GAP, 6 );
1263
+ return { top: Math.min( top, this._yOf( DAY_MINUTES ) - height ), height };
1264
+ }
1265
+
1266
+ // where an event that ends at a date stops on a day: 24:00 when it goes on after it
1267
+ private _minutesTo( day: Date, end: Date ) {
1268
+ return end.getTime( ) < date_add_days( day, 1 ).getTime( ) ? minutesOf( end ) : DAY_MINUTES;
1269
+ }
1270
+
1271
+ // the column at x in the canvas or the band, kept in the columns
1272
+ private _colOf( x: number ) {
1273
+ const cols = this._columns;
1274
+ for( let i = 0; i < cols.length; i++ ) {
1275
+ if( x < cols[i].x + cols[i].w ) {
1276
+ return i;
1277
+ }
1278
+ }
1279
+
1280
+ return cols.length - 1;
1281
+ }
1282
+
1283
+ // the day of the month at a point of the window, -1: none
1284
+ private _dayAt( x: number, y: number ) {
1285
+ return this._monthDays.findIndex( day => day.cell.getBoundingRect( ).contains( { x, y } ) );
1286
+ }
1287
+
1288
+ // the part of the planner of an element
1289
+ private _areaOf( el: Element ): PlannerDrag["area"] {
1290
+ if( this._canvas.dom.contains( el ) ) {
1291
+ return "canvas";
1292
+ }
1293
+
1294
+ if( this._band.dom.contains( el ) ) {
1295
+ return "band";
1296
+ }
1297
+
1298
+ if( this._cells.dom.contains( el ) ) {
1299
+ return "month";
1300
+ }
1301
+
1302
+ return null;
1303
+ }
1304
+
1305
+ // the day under the pointer, and its minutes in the canvas; null out of the days of the month
1306
+ private _pointAt( area: PlannerDrag["area"], ev: MouseEvent ): PlannerPoint {
1307
+ if( area=="month" ) {
1308
+ const i = this._dayAt( ev.clientX, ev.clientY );
1309
+ return i < 0 ? null : { day: this._monthDays[i].date, t: 0 };
1310
+ }
1311
+
1312
+ const rc = ( area=="canvas" ? this._canvas : this._band ).getBoundingRect( );
1313
+ const col = this._columns[this._colOf( ev.clientX - rc.left )];
1314
+ return { day: col.date, t: area=="canvas" ? this._tOf( ev.clientY - rc.top ) : 0 };
1315
+ }
1316
+
1317
+ // the event of an element
1318
+ private _itemOf( el: Element ): PlannerItem {
1319
+ const block = Component.closest( el, ".event" );
1320
+ if( block && this.contains( block ) ) {
1321
+ return block.getInternalData<PlannerItem>( "item" );
1322
+ }
1323
+
1324
+ return null;
1325
+ }
1326
+
1327
+ // :: SELECTION :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
1328
+
1329
+ private _select( item: PlannerItem, notify: boolean ) {
1330
+ const id = item ? item.event.id : undefined;
1331
+ if( id===this._selection ) {
1332
+ return;
1333
+ }
1334
+
1335
+ this._selection = id;
1336
+ this._blocks.forEach( block => block.setClass( "selected", block.getInternalData<PlannerItem>( "item" ).event.id===id ) );
1337
+
1338
+ if( notify ) {
1339
+ this.fire( "selectionChange", { selection: item ? [ id ] : [], empty: !item } );
1340
+ }
1341
+ }
1342
+
1343
+ // the week of a day of the month
1344
+ private _openWeek( date: Date ) {
1345
+ this._view = "week";
1346
+ this._date = dayOf( date );
1347
+ this._range = null;
1348
+ this._render( );
1349
+ this.fire( "viewChange", { view: this._view, date: this.getDate( ) } );
1350
+ }
1351
+
1352
+ private _onResize( ) {
1353
+ if( !this._drag ) {
1354
+ this._render( );
1355
+ }
1356
+ }
1357
+
1358
+ private _onDblClick( ev: MouseEvent ) {
1359
+ const item = this._itemOf( ev.target as Element );
1360
+ if( item ) {
1361
+ this.fire( "dblClick", { context: item.event } );
1362
+ return;
1363
+ }
1364
+
1365
+ if( this._view=="month" ) {
1366
+ const i = this._dayAt( ev.clientX, ev.clientY );
1367
+ if( i >= 0 ) {
1368
+ this._openWeek( this._monthDays[i].date );
1369
+ }
1370
+ }
1371
+ }
1372
+
1373
+ private _onContextMenu( ev: MouseEvent ) {
1374
+ ev.preventDefault( );
1375
+
1376
+ const item = this._itemOf( ev.target as Element );
1377
+ if( item ) {
1378
+ this._select( item, true );
1379
+ this.fire( "contextMenu", { uievent: ev, context: item.event } );
1380
+ }
1381
+ }
1382
+
1383
+ private _onKey( ev: KeyboardEvent ) {
1384
+ const drag = this._drag;
1385
+ if( ev.key=="Escape" && drag ) {
1386
+ ev.preventDefault( );
1387
+ this._onPointerCancel( );
1388
+
1389
+ if( drag.moved ) {
1390
+ this.releaseCapture( drag.pointer );
1391
+ }
1392
+ }
1393
+ }
1394
+
1395
+ // :: DRAG ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
1396
+
1397
+ /**
1398
+ * an event under the pointer: moved, or one of its ends; an empty place, the strip on the
1399
+ * right of a day or shift: a range. Nothing is taken before the pointer went 4px away: a
1400
+ * click stays a click
1401
+ */
1402
+
1403
+ private _onPointerDown( ev: PointerEvent ) {
1404
+ if( ev.button!=0 ) {
1405
+ return;
1406
+ }
1407
+
1408
+ const target = ev.target as Element;
1409
+ const area = this._areaOf( target );
1410
+ if( !area || Component.closest( target, ".edge" ) ) {
1411
+ return; // out of the days, or a mark of the events out of the view: its click
1412
+ }
1413
+
1414
+ // the hours, on the left
1415
+ let x = 0;
1416
+ if( area!="month" ) {
1417
+ x = ev.clientX - ( area=="canvas" ? this._canvas : this._band ).getBoundingRect( ).left;
1418
+ if( x < GUTTER ) {
1419
+ return;
1420
+ }
1421
+ }
1422
+
1423
+ const at = this._pointAt( area, ev );
1424
+ if( !at ) {
1425
+ return;
1426
+ }
1427
+
1428
+ this._range = null;
1429
+ this._renderRange( );
1430
+
1431
+ let item = ev.shiftKey ? null : this._itemOf( target );
1432
+ const block = item ? Component.closest( target, ".event" ) : null;
1433
+
1434
+ if( item && area=="canvas" ) {
1435
+ const col = this._columns[this._colOf( x )];
1436
+ if( x > col.x + col.w - Math.min( FREE_STRIP, col.w * 0.2 ) ) {
1437
+ item = null;
1438
+ }
1439
+ }
1440
+
1441
+ let kind: PlannerDrag["kind"] = item ? "move" : "select";
1442
+ const grip = item ? Component.closest( target, ".grip" ) : null;
1443
+ if( grip ) {
1444
+ kind = grip.hasClass( "start" ) ? "start" : "end";
1445
+ }
1446
+
1447
+ const t0 = kind=="select" ? Math.min( this._snap( at.t ), this._hoursShown( )[1] - this._step ) : at.t;
1448
+ this._drag = { area, kind, pointer: ev.pointerId, x: ev.clientX, y: ev.clientY, moved: false, copy: false, item, block, blocks: [], ghost: null, day: at.day, t0, preview: null };
1449
+ }
1450
+
1451
+ private _onPointerMove( ev: PointerEvent ) {
1452
+ const drag = this._drag;
1453
+ if( !drag ) {
1454
+ return;
1455
+ }
1456
+
1457
+ if( !drag.moved ) {
1458
+ // the button let go out of the planner, before any capture: no drag
1459
+ if( !( ev.buttons & 1 ) ) {
1460
+ this._drag = null;
1461
+ return;
1462
+ }
1463
+
1464
+ if( Math.abs( ev.clientX - drag.x ) < 4 && Math.abs( ev.clientY - drag.y ) < 4 ) {
1465
+ return;
1466
+ }
1467
+
1468
+ // read only: no range, an event is only clicked
1469
+ if( this._readonly ) {
1470
+ return;
1471
+ }
1472
+
1473
+ drag.moved = true;
1474
+ this.setCapture( ev.pointerId );
1475
+ this.addClass( drag.kind=="select" ? "selecting" : drag.kind=="move" ? "moving" : "sizing" );
1476
+
1477
+ if( drag.item ) {
1478
+ this._select( drag.item, true );
1479
+ drag.blocks = this._blocks.filter( block => block.getInternalData<PlannerItem>( "item" )===drag.item );
1480
+ }
1481
+ }
1482
+
1483
+ if( drag.area=="canvas" ) {
1484
+ this._autoScroll( ev );
1485
+ }
1486
+
1487
+ const at = this._pointAt( drag.area, ev );
1488
+ if( !at ) {
1489
+ return;
1490
+ }
1491
+
1492
+ if( drag.kind=="select" ) {
1493
+ this._range = this._rangeOf( drag, at );
1494
+ this._renderRange( );
1495
+ }
1496
+ else {
1497
+ drag.copy = drag.kind=="move" && isCopyKey( ev );
1498
+ drag.preview = this._place( drag, at );
1499
+ this._showDrag( drag, at, ev );
1500
+ }
1501
+ }
1502
+
1503
+ /**
1504
+ * the drag on screen, nothing else drawn again: a move shows a ghost where the event goes, its
1505
+ * parts faded at their place unless it is copied; an end stretches the part under the pointer
1506
+ */
1507
+
1508
+ private _showDrag( drag: PlannerDrag, at: PlannerPoint, ev: PointerEvent ) {
1509
+ const preview = drag.preview;
1510
+ const cols = this._columns;
1511
+
1512
+ if( drag.kind=="move" ) {
1513
+ drag.blocks.forEach( block => block.setClass( "faded", !drag.copy ) );
1514
+ this._removeGhost( drag );
1515
+ drag.ghost = this._makeGhost( drag, { ...drag.item, start: preview.start, end: preview.end }, at, ev );
1516
+ return;
1517
+ }
1518
+
1519
+ const block = drag.block;
1520
+ if( drag.area=="canvas" ) {
1521
+ const segment = block.getInternalData<PlannerSegment>( "segment" );
1522
+ const span = this._span( drag.kind=="start" ? minutesOf( preview.start ) : segment.from, drag.kind=="end" ? this._minutesTo( drag.day, preview.end ) : segment.to );
1523
+
1524
+ block.setStyle( span );
1525
+ block.setClass( "small", span.height < SMALL_EVENT );
1526
+ block.query( ".time" ).setContent( `${timeText( preview.start )} – ${timeText( preview.end )}` );
1527
+ }
1528
+ else {
1529
+ const shown = indicesOf( cols.map( col => col.date ), preview.start, preview.end );
1530
+ if( shown ) {
1531
+ const [ c0, c1 ] = shown;
1532
+ const x = cols[c0].x + 2;
1533
+ block.setStyle( { left: x, width: cols[c1].x + cols[c1].w - x - 3 } );
1534
+ }
1535
+ }
1536
+ }
1537
+
1538
+ /**
1539
+ * where the event moved goes, over the others: in the column of its new day, as wide as it is
1540
+ * and at its place in its column (alone in its day: as wide as the new one, shifted); in the
1541
+ * band on its new days, on its line; in the month under the pointer, the day it goes to framed
1542
+ */
1543
+
1544
+ private _makeGhost( drag: PlannerDrag, item: PlannerItem, at: PlannerPoint, ev: PointerEvent ): Component {
1545
+ const cols = this._columns;
1546
+ let ghost: Component;
1547
+
1548
+ switch( drag.area ) {
1549
+ case "canvas": {
1550
+ // its first day shown, out of the hours shown: no ghost
1551
+ const col = this._columnOf( item.start );
1552
+ const segment = col ? this._segmentOn( item, col.date ) : null;
1553
+ if( !segment ) {
1554
+ return null;
1555
+ }
1556
+
1557
+ // not taken for another one: the width of the part dragged, at its place in its column;
1558
+ // alone in its day, the width of the new one, shifted
1559
+ const span = this._span( segment.from, segment.to );
1560
+ const block = drag.block.dom as HTMLElement;
1561
+ const from = this._columnOf( drag.day );
1562
+
1563
+ let left = col.x + ( from ? block.offsetLeft - from.x : 2 );
1564
+ let width = block.offsetWidth;
1565
+ if( drag.block.getInternalData<boolean>( "alone" ) ) {
1566
+ left = col.x + 2 + GHOST_SHIFT;
1567
+ width = Math.max( 20, col.w - 6 - Math.min( FREE_STRIP, col.w * 0.2 ) - GHOST_SHIFT );
1568
+ }
1569
+
1570
+ ghost = this._makeEvent( segment );
1571
+ ghost.setStyle( { left, width, ...span } );
1572
+ ghost.setClass( "small", span.height < SMALL_EVENT );
1573
+ this._layer.appendContent( ghost );
1574
+ break;
1575
+ }
1576
+
1577
+ case "band": {
1578
+ const shown = indicesOf( cols.map( col => col.date ), item.start, item.end );
1579
+ if( !shown ) {
1580
+ return null;
1581
+ }
1582
+
1583
+ const [ c0, c1 ] = shown;
1584
+ const x = cols[c0].x + 2;
1585
+
1586
+ ghost = this._makeBar( item );
1587
+ ghost.setStyle( { left: x, width: cols[c1].x + cols[c1].w - x - 3, top: ( drag.block.dom as HTMLElement ).offsetTop, height: BAND_LINE - 2 } );
1588
+ this._band.appendContent( ghost );
1589
+ break;
1590
+ }
1591
+
1592
+ default: {
1593
+ const rc = this._cells.getBoundingRect( );
1594
+
1595
+ ghost = this._makeLine( item, at.day );
1596
+ ghost.setStyle( { left: ev.clientX - rc.left - 20, top: ev.clientY - rc.top - MONTH_LINE / 2, width: ( drag.block.dom as HTMLElement ).offsetWidth } );
1597
+ this._cells.appendContent( ghost );
1598
+ this._monthDays.forEach( day => day.cell.setClass( "drop", day.date.getTime( )==at.day.getTime( ) ) );
1599
+ break;
1600
+ }
1601
+ }
1602
+
1603
+ ghost.addClass( "dragging" );
1604
+ return ghost;
1605
+ }
1606
+
1607
+ private _removeGhost( drag: PlannerDrag ) {
1608
+ if( drag.ghost ) {
1609
+ drag.ghost.dom.remove( );
1610
+ drag.ghost = null;
1611
+ }
1612
+ }
1613
+
1614
+ /**
1615
+ * a drag in the hours near the top or the bottom of the body: it scrolls while the pointer
1616
+ * stays there, the drag following the time under the pointer
1617
+ */
1618
+
1619
+ private _autoScroll( ev: PointerEvent ) {
1620
+ const rc = this._body.getBoundingRect( );
1621
+ const dir = ev.clientY < rc.top + SCROLL_EDGE ? -1 : ev.clientY > rc.bottom - SCROLL_EDGE ? 1 : 0;
1622
+
1623
+ this._scrollEv = ev;
1624
+ if( dir==this._scrollDir ) {
1625
+ return;
1626
+ }
1627
+
1628
+ this._scrollDir = dir;
1629
+ if( dir ) {
1630
+ this.setInterval( "autoscroll", 20, ( ) => {
1631
+ this._body.dom.scrollTop += this._scrollDir * SCROLL_STEP;
1632
+ this._onPointerMove( this._scrollEv );
1633
+ } );
1634
+ }
1635
+ else {
1636
+ this.clearInterval( "autoscroll" );
1637
+ }
1638
+ }
1639
+
1640
+ // the end of a drag: no scroll, no ghost, no day framed, the cursor of the planner back
1641
+ private _endDrag( drag: PlannerDrag ) {
1642
+ this._scrollDir = 0;
1643
+ this.clearInterval( "autoscroll" );
1644
+ this._removeGhost( drag );
1645
+ this._monthDays.forEach( day => day.cell.removeClass( "drop" ) );
1646
+ this.removeClass( "selecting moving sizing" );
1647
+ }
1648
+
1649
+ /**
1650
+ * a range: told by select; an event: the change is told before it is written, ev.preventDefault( )
1651
+ * puts it back; a copy is told, the application adds it
1652
+ */
1653
+
1654
+ private _onPointerUp( ev: PointerEvent ) {
1655
+ const drag = this._drag;
1656
+ this._drag = null;
1657
+
1658
+ if( !drag ) {
1659
+ return;
1660
+ }
1661
+
1662
+ if( !drag.moved ) {
1663
+ this._onTap( ev, drag );
1664
+ return;
1665
+ }
1666
+
1667
+ this.releaseCapture( ev.pointerId );
1668
+ this._endDrag( drag );
1669
+
1670
+ if( drag.kind=="select" ) {
1671
+ const range = this._range;
1672
+ if( range ) {
1673
+ this.fire( "select", { start: range.start, end: endOf( range.start, range.end ) } );
1674
+ }
1675
+ return;
1676
+ }
1677
+
1678
+ const preview = drag.preview;
1679
+ if( !preview ) {
1680
+ this._renderItems( );
1681
+ return;
1682
+ }
1683
+
1684
+ const event = drag.item.event;
1685
+ const change: EvPlannerChange = { event, start: preview.start, end: endOf( preview.start, preview.end ) };
1686
+
1687
+ if( drag.kind=="move" && ( drag.copy || isCopyKey( ev ) ) ) {
1688
+ this.fire( "copy", change );
1689
+ this._renderItems( );
1690
+ return;
1691
+ }
1692
+
1693
+ this.fire( "change", change );
1694
+
1695
+ if( !change.defaultPrevented ) {
1696
+ event.start = change.start;
1697
+ event.end = change.end;
1698
+ }
1699
+
1700
+ this._render( );
1701
+ }
1702
+
1703
+ // a click: an event selected, or none; on the "+n" of a day of the month, its week
1704
+ private _onTap( ev: PointerEvent, drag: PlannerDrag ) {
1705
+ if( drag.area=="month" && Component.closest( ev.target as Element, ".more" ) ) {
1706
+ this._openWeek( drag.day );
1707
+ return;
1708
+ }
1709
+
1710
+ this._select( drag.item, true );
1711
+
1712
+ if( drag.item ) {
1713
+ this.fire( "click", { context: drag.item.event } );
1714
+ }
1715
+ }
1716
+
1717
+ // the browser took the pointer, the capture was lost, Escape: the event goes back, no range.
1718
+ // After a pointerup the drag is already over: the capture it releases changes nothing
1719
+ private _onPointerCancel( ) {
1720
+ const drag = this._drag;
1721
+ this._drag = null;
1722
+
1723
+ if( drag && drag.moved ) {
1724
+ this._endDrag( drag );
1725
+ this._range = null;
1726
+ this._renderItems( );
1727
+ }
1728
+ }
1729
+
1730
+ // the range from where the pointer went down: the steps of its day in the canvas, else the days
1731
+ private _rangeOf( drag: PlannerDrag, at: PlannerPoint ): PlannerRange {
1732
+ if( drag.area=="canvas" ) {
1733
+ const t1 = Math.min( this._snap( at.t ), this._hoursShown( )[1] - this._step );
1734
+ return { area: drag.area, start: atMinutes( drag.day, Math.min( drag.t0, t1 ) ), end: atMinutes( drag.day, Math.max( drag.t0, t1 ) + this._step ) };
1735
+ }
1736
+
1737
+ const after = at.day.getTime( ) > drag.day.getTime( );
1738
+ return { area: drag.area, start: after ? drag.day : at.day, end: date_add_days( after ? at.day : drag.day, 1 ) };
1739
+ }
1740
+
1741
+ /**
1742
+ * where the event dragged goes: moved by the days and the steps the pointer went, or one of its
1743
+ * ends. In the canvas, an event of one day stays in the hours shown of its day: only its column
1744
+ * changes its day; a whole day changes of day only
1745
+ */
1746
+
1747
+ private _place( drag: PlannerDrag, at: PlannerPoint ): { start: Date, end: Date } {
1748
+ const item = drag.item;
1749
+ const step = this._step * 60000;
1750
+ const canvas = drag.area=="canvas";
1751
+
1752
+ switch( drag.kind ) {
1753
+ case "move": {
1754
+ const days = daysBetween( drag.day, at.day );
1755
+ const minutes = canvas && !item.allDay ? this._snapRound( at.t - drag.t0 ) : 0;
1756
+ const length = item.end.getTime( ) - item.start.getTime( );
1757
+
1758
+ if( canvas && !item.allDay && daysBetween( dayOf( item.start ), dayOf( new Date( item.end.getTime( ) - 1 ) ) )==0 ) {
1759
+ const [ shownStart, shownEnd ] = this._hoursShown( );
1760
+ const t = clamp( minutesOf( item.start ) + minutes, shownStart, Math.max( shownStart, shownEnd - length / 60000 ) );
1761
+ const start = atMinutes( date_add_days( dayOf( item.start ), days ), t );
1762
+ return { start, end: new Date( start.getTime( ) + length ) };
1763
+ }
1764
+
1765
+ const start = date_add_days( item.start, days );
1766
+ const end = date_add_days( item.end, days );
1767
+ start.setMinutes( start.getMinutes( ) + minutes );
1768
+ end.setMinutes( end.getMinutes( ) + minutes );
1769
+ return { start, end };
1770
+ }
1771
+
1772
+ case "start": {
1773
+ if( canvas ) {
1774
+ const start = atMinutes( drag.day, this._snapRound( at.t ) ).getTime( );
1775
+ return { start: new Date( Math.min( start, item.end.getTime( ) - step ) ), end: item.end };
1776
+ }
1777
+
1778
+ const last = date_add_days( item.end, -1 );
1779
+ return { start: at.day.getTime( ) > last.getTime( ) ? last : at.day, end: item.end };
1780
+ }
1781
+
1782
+ default: {
1783
+ if( canvas ) {
1784
+ const end = atMinutes( drag.day, this._snapRound( at.t ) ).getTime( );
1785
+ return { start: item.start, end: new Date( Math.max( end, item.start.getTime( ) + step ) ) };
1786
+ }
1787
+
1788
+ return { start: item.start, end: date_add_days( at.day.getTime( ) < item.start.getTime( ) ? item.start : at.day, 1 ) };
1789
+ }
1790
+ }
1791
+ }
1792
+ }