@wimi/gantt 0.1.0

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.
@@ -0,0 +1,1924 @@
1
+ import './vanilla-gantt.css'
2
+
3
+ const SVG_NS = 'http://www.w3.org/2000/svg'
4
+ const HOUR = 60 * 60 * 1000
5
+ const DEFAULT_TASK_TOOLTIP = {
6
+ visible: true,
7
+ customLayout: null,
8
+ className: '',
9
+ offsetX: 12,
10
+ offsetY: 12
11
+ }
12
+
13
+ const DEFAULT_OPTIONS = {
14
+ records: [],
15
+ recordKeyField: 'id',
16
+ taskKeyField: 'id',
17
+ minDate: '',
18
+ maxDate: '',
19
+ rowHeight: 78,
20
+ taskHeight: 36,
21
+ headerRowHeight: 24,
22
+ taskListTable: {
23
+ tableWidth: 'auto',
24
+ minTableWidth: 120,
25
+ maxTableWidth: 640,
26
+ columnResizable: true,
27
+ headerStyle: null,
28
+ columns: [
29
+ { field: 'name', title: '工位', width: 170, tree: true }
30
+ ],
31
+ renderHeader: null,
32
+ renderCell: null
33
+ },
34
+ timelineHeader: {
35
+ backgroundColor: '#fff',
36
+ colWidth: 56,
37
+ style: null,
38
+ scales: [
39
+ { unit: 'day', step: 1, rowHeight: 24 },
40
+ { unit: 'hour', step: 2, rowHeight: 24, colWidth: 56 }
41
+ ]
42
+ },
43
+ taskBar: {
44
+ tasksField: 'tasks',
45
+ startDateField: 'startDate',
46
+ endDateField: 'endDate',
47
+ progressField: 'progress',
48
+ laneField: 'lane',
49
+ statusField: 'status',
50
+ labelText: 'title',
51
+ subLabelText: 'subtitle',
52
+ barStyle: null,
53
+ projectStyle: null,
54
+ customLayout: null,
55
+ clip: true,
56
+ draggable: true,
57
+ dragStep: 60 * 1000,
58
+ tooltip: false,
59
+ onClick: null,
60
+ onContextMenu: null,
61
+ onMouseEnter: null,
62
+ onMouseLeave: null,
63
+ onDragStart: null,
64
+ onDrag: null,
65
+ onDragEnd: null,
66
+ lanes: [
67
+ { key: 'plan', offset: 8, height: 36 },
68
+ { key: 'load', offset: 52, height: 6 },
69
+ { key: 'unload', offset: 66, height: 6 }
70
+ ]
71
+ },
72
+ dependency: {
73
+ links: [],
74
+ linkLineStyle: null,
75
+ showLinks: false,
76
+ highlightConnected: false,
77
+ dimOpacity: 0.18
78
+ },
79
+ grid: {
80
+ backgroundColor: '#f7fbfb',
81
+ alternatingBackgroundColor: '#f8fbfb',
82
+ verticalLine: { lineColor: '#e8eeee' },
83
+ horizontalLine: { lineColor: '#edf1f2' },
84
+ backgroundRanges: [],
85
+ rowBackgroundRanges: []
86
+ },
87
+ markLine: null,
88
+ onScroll: null
89
+ }
90
+
91
+ export default class VanillaGantt {
92
+ constructor(container, options = {}) {
93
+ this.container = typeof container === 'string' ? document.querySelector(container) : container
94
+ if (!this.container) {
95
+ throw new Error('VanillaGantt requires a valid container.')
96
+ }
97
+
98
+ this.options = mergeOptions(DEFAULT_OPTIONS, options)
99
+ this.expandedRows = {}
100
+ this.scrollTop = 0
101
+ this.scrollLeft = 0
102
+ this.disposers = []
103
+ this.activeDrag = null
104
+ this.activeLinkTaskKey = null
105
+ this.activeLinkGroupKey = null
106
+ this.suppressClickUntil = 0
107
+ this.suppressClickTaskKey = null
108
+ this.seedExpandedRows(this.options.records)
109
+ this.render()
110
+ }
111
+
112
+ setOptions(options = {}) {
113
+ const previousRecords = this.options.records
114
+ this.options = mergeOptions(this.options, options)
115
+ if (options.records && options.records !== previousRecords) {
116
+ this.seedExpandedRows(options.records, true)
117
+ }
118
+ this.render()
119
+ }
120
+
121
+ destroy() {
122
+ this.cleanup()
123
+ this.container.innerHTML = ''
124
+ }
125
+
126
+ cleanup() {
127
+ this.hideTaskTooltip()
128
+ this.disposers.forEach(dispose => dispose())
129
+ this.disposers = []
130
+ }
131
+
132
+ render() {
133
+ this.cleanup()
134
+
135
+ const root = el('div', 'vg')
136
+ root.style.setProperty('--vg-left-width', `${this.tableWidth}px`)
137
+ this.rootEl = root
138
+
139
+ const left = el('div', 'vg-left')
140
+ const right = el('div', 'vg-right')
141
+
142
+ left.append(this.renderLeftHeader(), this.renderLeftBody(), this.renderResizeHandle(root))
143
+ right.append(this.renderTimeline(), this.renderBody())
144
+ root.append(left, right)
145
+
146
+ this.container.innerHTML = ''
147
+ this.container.append(root)
148
+
149
+ if (this.scrollEl) {
150
+ this.scrollEl.scrollTop = this.scrollTop
151
+ this.scrollEl.scrollLeft = this.scrollLeft
152
+ }
153
+ }
154
+
155
+ renderLeftHeader() {
156
+ const header = el('div', 'vg-left-header')
157
+ header.style.height = `${this.headerHeight}px`
158
+ header.style.gridTemplateColumns = this.tableGridTemplateColumns
159
+ applyTableHeaderStyle(header, this.taskListTable.headerStyle)
160
+ this.leftHeaderEl = header
161
+ const custom = this.resolveContent(this.taskListTable.renderHeader, { gantt: this })
162
+ if (custom) {
163
+ header.append(custom)
164
+ } else {
165
+ this.tableColumns.forEach((column, columnIndex) => {
166
+ header.append(this.renderTableHeaderCell(column, columnIndex))
167
+ })
168
+ }
169
+ return header
170
+ }
171
+
172
+ renderTableHeaderCell(column, columnIndex) {
173
+ const cell = el('div', `vg-table-header-cell${column.className ? ` ${column.className}` : ''}`)
174
+ const headerStyle = {
175
+ ...(this.taskListTable.headerStyle || {}),
176
+ ...(column.headerStyle || {})
177
+ }
178
+ applyTableHeaderStyle(cell, headerStyle)
179
+ const content = el('div', 'vg-table-header-content')
180
+ content.style.justifyContent = alignToFlex(column.headerAlign || headerStyle.textAlign || column.align || 'left')
181
+ const custom = this.resolveContent(column.renderHeader, {
182
+ column,
183
+ columnIndex,
184
+ gantt: this
185
+ })
186
+ if (custom) {
187
+ content.append(custom)
188
+ } else {
189
+ content.textContent = column.title || column.field || ''
190
+ }
191
+ cell.append(content)
192
+
193
+ if (this.isColumnResizable(column)) {
194
+ cell.append(this.renderColumnResizeHandle(column, columnIndex))
195
+ }
196
+ return cell
197
+ }
198
+
199
+ renderColumnResizeHandle(column, columnIndex) {
200
+ const handle = el('span', 'vg-column-resize-handle')
201
+ const onMouseDown = event => {
202
+ const startX = event.clientX
203
+ const startWidth = this.columnWidth(column)
204
+ const onMove = moveEvent => {
205
+ const next = startWidth + moveEvent.clientX - startX
206
+ this.resizeColumn(columnIndex, next)
207
+ }
208
+ const onUp = () => {
209
+ document.removeEventListener('mousemove', onMove)
210
+ document.removeEventListener('mouseup', onUp)
211
+ }
212
+ document.addEventListener('mousemove', onMove)
213
+ document.addEventListener('mouseup', onUp)
214
+ event.preventDefault()
215
+ event.stopPropagation()
216
+ }
217
+ handle.addEventListener('mousedown', onMouseDown)
218
+ this.disposers.push(() => handle.removeEventListener('mousedown', onMouseDown))
219
+ return handle
220
+ }
221
+
222
+ resizeColumn(columnIndex, width) {
223
+ const sourceColumn = this.sourceTableColumns[columnIndex]
224
+ if (!sourceColumn) return
225
+
226
+ const minWidth = Number(sourceColumn.minWidth || 48)
227
+ const maxWidth = Number(sourceColumn.maxWidth || 600)
228
+ sourceColumn.width = Math.min(maxWidth, Math.max(minWidth, Math.round(width)))
229
+ if (typeof this.taskListTable.tableWidth === 'number') {
230
+ this.taskListTable.tableWidth = this.sourceTableColumns.reduce((total, item) => total + this.columnWidth(item), 0)
231
+ }
232
+ this.syncTableLayout()
233
+ }
234
+
235
+ syncTableLayout() {
236
+ const width = this.tableWidth
237
+ if (this.rootEl) this.rootEl.style.setProperty('--vg-left-width', `${width}px`)
238
+ if (this.leftHeaderEl) this.leftHeaderEl.style.gridTemplateColumns = this.tableGridTemplateColumns
239
+ if (this.leftBodyInner) {
240
+ Array.from(this.leftBodyInner.children).forEach(row => {
241
+ row.style.gridTemplateColumns = this.tableGridTemplateColumns
242
+ })
243
+ }
244
+ }
245
+
246
+ renderLeftBody() {
247
+ const body = el('div', 'vg-left-body')
248
+ const inner = el('div', 'vg-left-body-inner')
249
+ inner.style.height = `${this.bodyHeight}px`
250
+ inner.style.transform = `translateY(-${this.scrollTop}px)`
251
+ this.leftBodyInner = inner
252
+
253
+ this.visibleRows.forEach(row => {
254
+ const rowEl = el('div', `vg-row${row.children ? ' vg-row--group' : ''}`)
255
+ rowEl.style.height = `${row.height || this.options.rowHeight}px`
256
+ rowEl.style.gridTemplateColumns = this.tableGridTemplateColumns
257
+ this.tableColumns.forEach((column, columnIndex) => {
258
+ rowEl.append(this.renderTableCell(row, column, columnIndex))
259
+ })
260
+ inner.append(rowEl)
261
+ })
262
+
263
+ body.append(inner)
264
+ const onWheel = event => {
265
+ if (!this.scrollEl || !event.deltaY) return
266
+ this.scrollEl.scrollTop += event.deltaY
267
+ event.preventDefault()
268
+ }
269
+ body.addEventListener('wheel', onWheel, { passive: false })
270
+ this.disposers.push(() => body.removeEventListener('wheel', onWheel))
271
+ return body
272
+ }
273
+
274
+ renderTableCell(row, column, columnIndex) {
275
+ const cell = el('div', `vg-table-cell${column.className ? ` ${column.className}` : ''}`)
276
+ cell.style.justifyContent = alignToFlex(column.align || 'left')
277
+
278
+ const value = this.getCellValue(row, column, columnIndex)
279
+ const payload = {
280
+ value,
281
+ record: row,
282
+ row,
283
+ column,
284
+ columnIndex,
285
+ expanded: this.isExpanded(row),
286
+ toggle: () => this.toggleRow(row),
287
+ gantt: this
288
+ }
289
+ const custom = this.resolveContent(column.renderCell || this.taskListTable.renderCell, payload)
290
+ if (custom) {
291
+ cell.append(custom)
292
+ this.bindTemplateToggle(cell, row)
293
+ return cell
294
+ }
295
+
296
+ if (this.isTreeColumn(column, columnIndex)) {
297
+ cell.append(this.renderTreeCell(row, value))
298
+ } else if (column.field === 'load') {
299
+ cell.append(this.renderLoadCell(row, value))
300
+ } else {
301
+ cell.textContent = value === undefined || value === null ? '' : String(value)
302
+ }
303
+ return cell
304
+ }
305
+
306
+ renderTreeCell(row, value) {
307
+ const name = el('div', `vg-row-name${row.children ? ' vg-row-name--toggle' : ''}`)
308
+ name.style.paddingLeft = `${row.level * 16}px`
309
+ if (row.children) {
310
+ const button = el('button', 'vg-row-toggle')
311
+ button.type = 'button'
312
+ button.setAttribute('aria-label', this.isExpanded(row) ? '收起' : '展开')
313
+ button.setAttribute('aria-expanded', String(this.isExpanded(row)))
314
+ if (this.isExpanded(row)) {
315
+ button.classList.add('vg-row-toggle--expanded')
316
+ }
317
+ name.append(button)
318
+ name.addEventListener('click', () => this.toggleRow(row))
319
+ }
320
+ name.append(document.createTextNode(value === undefined || value === null ? '' : String(value)))
321
+ return name
322
+ }
323
+
324
+ renderLoadCell(row, value) {
325
+ const loadValue = value === undefined ? row.load : value
326
+ if (loadValue === undefined || loadValue === null) return document.createTextNode('')
327
+
328
+ const load = el('div', 'vg-row-load')
329
+ const bar = el('i')
330
+ bar.style.width = `${loadValue}%`
331
+ bar.style.background = this.loadColor(loadValue)
332
+ const valueNode = el('b')
333
+ valueNode.textContent = `${loadValue}%`
334
+ valueNode.style.color = this.loadColor(loadValue)
335
+ load.append(bar, valueNode)
336
+ return load
337
+ }
338
+
339
+ getCellValue(row, column, columnIndex) {
340
+ if (typeof column.valueGetter === 'function') {
341
+ return column.valueGetter({
342
+ record: row,
343
+ row,
344
+ column,
345
+ columnIndex,
346
+ gantt: this
347
+ })
348
+ }
349
+ return column.field ? row[column.field] : undefined
350
+ }
351
+
352
+ isTreeColumn(column, columnIndex) {
353
+ return column.tree === true || (column.tree !== false && columnIndex === 0 && column.field === 'name')
354
+ }
355
+
356
+ isColumnResizable(column) {
357
+ if (column.resizable === false) return false
358
+ return this.taskListTable.columnResizable !== false
359
+ }
360
+
361
+ bindTemplateToggle(root, row) {
362
+ if (!row.children) return
363
+ root.querySelectorAll('[data-vg-toggle]').forEach(node => {
364
+ const onClick = event => {
365
+ event.preventDefault()
366
+ event.stopPropagation()
367
+ this.toggleRow(row)
368
+ }
369
+ node.addEventListener('click', onClick)
370
+ this.disposers.push(() => node.removeEventListener('click', onClick))
371
+ })
372
+ }
373
+
374
+ renderResizeHandle(root) {
375
+ const handle = el('span', 'vg-resize-handle')
376
+ const onMouseDown = event => {
377
+ const startX = event.clientX
378
+ const startWidth = this.tableWidth
379
+ const onMove = moveEvent => {
380
+ const next = startWidth + moveEvent.clientX - startX
381
+ this.taskListTable.tableWidth = Math.min(
382
+ this.taskListTable.maxTableWidth,
383
+ Math.max(this.taskListTable.minTableWidth, next)
384
+ )
385
+ root.style.setProperty('--vg-left-width', `${this.taskListTable.tableWidth}px`)
386
+ }
387
+ const onUp = () => {
388
+ document.removeEventListener('mousemove', onMove)
389
+ document.removeEventListener('mouseup', onUp)
390
+ }
391
+ document.addEventListener('mousemove', onMove)
392
+ document.addEventListener('mouseup', onUp)
393
+ event.preventDefault()
394
+ }
395
+ handle.addEventListener('mousedown', onMouseDown)
396
+ this.disposers.push(() => handle.removeEventListener('mousedown', onMouseDown))
397
+ return handle
398
+ }
399
+
400
+ renderTimeline() {
401
+ const viewport = el('div', 'vg-timeline-viewport')
402
+ viewport.style.height = `${this.headerHeight}px`
403
+ if (this.timelineHeader.backgroundColor) {
404
+ viewport.style.background = this.timelineHeader.backgroundColor
405
+ }
406
+
407
+ const stage = el('div', 'vg-timeline-stage')
408
+ stage.style.width = `${this.chartWidth}px`
409
+ stage.style.transform = `translateX(-${this.scrollLeft}px)`
410
+ this.timelineStage = stage
411
+
412
+ const svg = svgEl('svg', 'vg-timeline-svg')
413
+ attrs(svg, {
414
+ width: this.chartWidth,
415
+ height: this.headerHeight,
416
+ viewBox: `0 0 ${this.chartWidth} ${this.headerHeight}`
417
+ })
418
+ if (this.timelineHeader.backgroundColor) {
419
+ svg.style.background = this.timelineHeader.backgroundColor
420
+ }
421
+
422
+ let y = 0
423
+ this.timelineUnitsByScale.forEach((units, scaleIndex) => {
424
+ const scale = this.timelineScales[scaleIndex]
425
+ const height = this.scaleRowHeight(scale)
426
+ units.forEach(unit => {
427
+ svg.append(this.renderTimelineUnit(unit, y, height, scale, scaleIndex))
428
+ })
429
+ y += height
430
+ })
431
+
432
+ stage.append(svg)
433
+ viewport.append(stage)
434
+ return viewport
435
+ }
436
+
437
+ renderTimelineUnit(unit, y, height, scale, scaleIndex) {
438
+ const fo = svgEl('foreignObject')
439
+ attrs(fo, {
440
+ x: unit.x,
441
+ y,
442
+ width: unit.width,
443
+ height
444
+ })
445
+ const custom = this.resolveContent(scale.customLayout || this.timelineHeader.customLayout, {
446
+ dateInfo: unit,
447
+ unit,
448
+ scale,
449
+ scaleIndex,
450
+ major: scaleIndex === 0,
451
+ gantt: this
452
+ })
453
+ const style = this.timelineCellStyle(scale)
454
+ if (custom) {
455
+ applyTimelineStyleToContent(custom, this.timelineCustomCellStyle(scale))
456
+ fo.append(custom)
457
+ } else {
458
+ const cell = el('div', `vg-timeline-cell${scaleIndex === 0 ? ' vg-timeline-cell--major' : ''}`)
459
+ applyTimelineStyle(cell, style)
460
+ cell.textContent = unit.label
461
+ fo.append(cell)
462
+ }
463
+ return fo
464
+ }
465
+
466
+ renderBody() {
467
+ const scroll = el('div', 'vg-scroll')
468
+ const stage = el('div', 'vg-stage')
469
+ stage.style.width = `${this.chartWidth}px`
470
+ stage.style.minWidth = `${this.chartWidth}px`
471
+
472
+ const svg = svgEl('svg', 'vg-svg')
473
+ attrs(svg, {
474
+ width: this.chartWidth,
475
+ height: this.bodyHeight,
476
+ viewBox: `0 0 ${this.chartWidth} ${this.bodyHeight}`
477
+ })
478
+
479
+ svg.append(this.renderDefs())
480
+ this.appendGrid(svg)
481
+ const onCanvasClick = () => {
482
+ if (this.clearActiveLinkGroup()) this.render()
483
+ }
484
+ svg.addEventListener('click', onCanvasClick)
485
+ this.disposers.push(() => svg.removeEventListener('click', onCanvasClick))
486
+ stage.append(svg)
487
+ scroll.append(stage)
488
+
489
+ const onScroll = () => {
490
+ this.scrollTop = scroll.scrollTop
491
+ this.scrollLeft = scroll.scrollLeft
492
+ if (this.leftBodyInner) {
493
+ this.leftBodyInner.style.transform = `translateY(-${this.scrollTop}px)`
494
+ }
495
+ if (this.timelineStage) {
496
+ this.timelineStage.style.transform = `translateX(-${this.scrollLeft}px)`
497
+ }
498
+ if (typeof this.options.onScroll === 'function') {
499
+ this.options.onScroll({ scrollLeft: this.scrollLeft, scrollTop: this.scrollTop })
500
+ }
501
+ }
502
+ scroll.addEventListener('scroll', onScroll)
503
+ this.disposers.push(() => scroll.removeEventListener('scroll', onScroll))
504
+ this.scrollEl = scroll
505
+
506
+ return scroll
507
+ }
508
+
509
+ renderDefs() {
510
+ const defs = svgEl('defs')
511
+ const pattern = svgEl('pattern')
512
+ attrs(pattern, {
513
+ id: 'vg-diagonal-stripe',
514
+ patternUnits: 'userSpaceOnUse',
515
+ width: 8,
516
+ height: 8
517
+ })
518
+ const path = svgEl('path')
519
+ attrs(path, {
520
+ d: 'M-2,2 l4,-4 M0,8 l8,-8 M6,10 l4,-4',
521
+ stroke: '#86ddd4',
522
+ 'stroke-width': 1
523
+ })
524
+ pattern.append(path)
525
+ defs.append(pattern)
526
+ return defs
527
+ }
528
+
529
+ appendGrid(svg) {
530
+ if (this.grid.backgroundColor) {
531
+ svg.append(this.rect(0, 0, this.chartWidth, this.bodyHeight, this.grid.backgroundColor))
532
+ }
533
+
534
+ this.backgroundShades.forEach(shade => {
535
+ svg.append(this.rect(shade.x, 0, shade.width, this.bodyHeight, shade.fill))
536
+ })
537
+
538
+ this.visibleBackgroundRanges.forEach(range => {
539
+ const rect = this.rect(
540
+ this.timeToX(range.startDate),
541
+ 0,
542
+ this.durationWidth(range.startDate, range.endDate),
543
+ this.bodyHeight,
544
+ range.color || range.fill || '#edf1f1'
545
+ )
546
+ rect.setAttribute('opacity', range.opacity === undefined ? 1 : range.opacity)
547
+ svg.append(rect)
548
+ })
549
+
550
+ this.verticalLines.forEach((item, index) => {
551
+ const style = this.resolveStyle(this.grid.verticalLine, { index, dateIndex: index, date: item.startDate, ganttInstance: this })
552
+ const line = this.line(item.x, 0, item.x, this.bodyHeight, style.lineColor || '#e8eeee')
553
+ this.applyLineStyle(line, style)
554
+ svg.append(line)
555
+ })
556
+
557
+ this.rowLines.forEach((item, index) => {
558
+ const style = this.resolveStyle(this.grid.horizontalLine, { index, ganttInstance: this })
559
+ const line = this.line(0, item.y, this.chartWidth, item.y, style.lineColor || '#edf1f2')
560
+ this.applyLineStyle(line, style)
561
+ svg.append(line)
562
+ })
563
+
564
+ this.markLines.forEach(markLine => {
565
+ this.appendMarkLine(svg, markLine)
566
+ })
567
+
568
+ this.visibleRowBackgroundRanges.forEach(range => {
569
+ const rect = this.rect(
570
+ this.timeToX(range.startDate),
571
+ this.rowTop(range.recordKey) + (range.offsetY || 0),
572
+ this.durationWidth(range.startDate, range.endDate),
573
+ range.height || this.options.rowHeight,
574
+ range.color || range.fill || '#dcf8c9'
575
+ )
576
+ rect.setAttribute('opacity', range.opacity === undefined ? 1 : range.opacity)
577
+ svg.append(rect)
578
+ })
579
+
580
+ this.stripedTasks.forEach(task => {
581
+ const rect = this.rect(
582
+ this.timeToX(this.taskStart(task)),
583
+ this.taskY(task),
584
+ this.durationWidth(this.taskStart(task), this.taskEnd(task)),
585
+ this.taskRenderHeight(task),
586
+ 'url(#vg-diagonal-stripe)'
587
+ )
588
+ svg.append(rect)
589
+ })
590
+
591
+ this.renderTasks.forEach(task => {
592
+ svg.append(this.renderTask(task))
593
+ })
594
+
595
+ this.visibleLinks.forEach(link => {
596
+ this.appendLink(svg, link)
597
+ })
598
+ }
599
+
600
+ appendMarkLine(svg, markLine) {
601
+ const x = this.timeToX(markLine.date)
602
+ if (x < 0 || x > this.chartWidth) return
603
+
604
+ const style = markLine.style || {}
605
+ const line = this.line(x, 0, x, this.bodyHeight, style.lineColor || '#35cce0')
606
+ this.applyLineStyle(line, { lineDash: [4, 4], ...style })
607
+ svg.append(line)
608
+
609
+ if (!markLine.content) return
610
+ const text = svgEl('text', 'vg-mark-line-text')
611
+ attrs(text, {
612
+ x: x + 6,
613
+ y: 16,
614
+ fill: markLine.contentStyle && markLine.contentStyle.color ? markLine.contentStyle.color : style.lineColor || '#35cce0'
615
+ })
616
+ text.textContent = markLine.content
617
+ svg.append(text)
618
+ }
619
+
620
+ appendLink(svg, link) {
621
+ const from = this.taskLayoutByKey[link.from]
622
+ const to = this.taskLayoutByKey[link.to]
623
+ if (!from || !to) return
624
+
625
+ const route = this.linkRoute(link.type, from, to)
626
+ const style = {
627
+ ...(this.dependency.linkLineStyle || {}),
628
+ ...(link.linkLineStyle || {})
629
+ }
630
+ const color = style.lineColor || link.color || '#168dff'
631
+ const path = this.path(route.d, color)
632
+ attrs(path, {
633
+ fill: 'none',
634
+ 'stroke-linejoin': 'round',
635
+ 'stroke-linecap': 'round'
636
+ })
637
+ this.applyLineStyle(path, { lineWidth: 2, ...style })
638
+ if (link.dashed) path.setAttribute('stroke-dasharray', '6 4')
639
+ svg.append(path)
640
+ svg.append(this.circle(route.start.x, route.start.y, 4, color))
641
+ svg.append(this.arrow(route.end.x, route.end.y, route.direction, color))
642
+ }
643
+
644
+ linkRoute(type, from, to) {
645
+ const fromStart = from.x
646
+ const fromEnd = from.x + from.width
647
+ const toStart = to.x
648
+ const toEnd = to.x + to.width
649
+ const normalizedType = type || 'finish_to_start'
650
+ let start
651
+ let end
652
+
653
+ if (normalizedType === 'start_to_start') {
654
+ start = { x: fromStart, y: from.centerY }
655
+ end = { x: toStart, y: to.centerY }
656
+ } else if (normalizedType === 'finish_to_finish') {
657
+ start = { x: fromEnd, y: from.centerY }
658
+ end = { x: toEnd, y: to.centerY }
659
+ } else if (normalizedType === 'start_to_finish') {
660
+ start = { x: fromStart, y: from.centerY }
661
+ end = { x: toEnd, y: to.centerY }
662
+ } else {
663
+ start = { x: fromEnd, y: from.centerY }
664
+ end = { x: toStart, y: to.centerY }
665
+ }
666
+
667
+ const direction = end.x >= start.x ? 1 : -1
668
+ const gap = Math.abs(end.x - start.x)
669
+ const elbow = direction > 0
670
+ ? start.x + Math.max(20, gap / 2)
671
+ : start.x + 28
672
+ return {
673
+ start,
674
+ end,
675
+ direction,
676
+ d: `M ${start.x} ${start.y} H ${elbow} V ${end.y} H ${end.x}`
677
+ }
678
+ }
679
+
680
+ renderTask(task) {
681
+ const width = this.durationWidth(this.taskStart(task), this.taskEnd(task))
682
+ const height = this.taskRenderHeight(task)
683
+ const fo = svgEl('foreignObject', 'vg-task-fo')
684
+ attrs(fo, {
685
+ x: this.timeToX(this.taskStart(task)),
686
+ y: this.taskY(task),
687
+ width,
688
+ height
689
+ })
690
+
691
+ const row = this.rowById[task.__rowId]
692
+ const payload = this.createTaskPayload(task, {
693
+ row,
694
+ width,
695
+ height,
696
+ x: this.timeToX(this.taskStart(task)),
697
+ y: this.taskY(task)
698
+ })
699
+ const custom = this.resolveContent(this.taskBar.customLayout, payload)
700
+ fo.append(custom || this.renderDefaultTask(task))
701
+ if (this.isTaskDimmed(task)) {
702
+ fo.classList.add('vg-task-fo--dimmed')
703
+ fo.style.opacity = String(this.dependency.dimOpacity === undefined ? 0.18 : this.dependency.dimOpacity)
704
+ }
705
+ this.bindTaskInteractions(fo, task)
706
+ return fo
707
+ }
708
+
709
+ bindTaskInteractions(node, task) {
710
+ if (this.isTaskDraggable(task)) {
711
+ node.classList.add('vg-task-fo--draggable')
712
+ }
713
+
714
+ const onClick = event => {
715
+ const taskKey = this.taskKey(task)
716
+ if (this.suppressClickTaskKey === taskKey && Date.now() < this.suppressClickUntil) {
717
+ event.preventDefault()
718
+ event.stopPropagation()
719
+ return
720
+ }
721
+ const shouldRender = this.activateTaskLinkGroup(task)
722
+ this.callTaskCallback('onClick', task, event)
723
+ event.stopPropagation()
724
+ if (shouldRender) this.render()
725
+ }
726
+ const onContextMenu = event => {
727
+ if (typeof this.taskBar.onContextMenu !== 'function') return
728
+ event.preventDefault()
729
+ this.callTaskCallback('onContextMenu', task, event)
730
+ }
731
+ const onMouseEnter = event => {
732
+ this.callTaskCallback('onMouseEnter', task, event)
733
+ this.showTaskTooltip(task, event)
734
+ }
735
+ const onMouseMove = event => {
736
+ this.positionTaskTooltip(event)
737
+ }
738
+ const onMouseLeave = event => {
739
+ this.callTaskCallback('onMouseLeave', task, event)
740
+ this.hideTaskTooltip()
741
+ }
742
+ const onPointerDown = event => {
743
+ this.startTaskDrag(node, task, event)
744
+ }
745
+
746
+ node.addEventListener('click', onClick)
747
+ node.addEventListener('contextmenu', onContextMenu)
748
+ node.addEventListener('mouseenter', onMouseEnter)
749
+ node.addEventListener('mousemove', onMouseMove)
750
+ node.addEventListener('mouseleave', onMouseLeave)
751
+ node.addEventListener('pointerdown', onPointerDown)
752
+ this.disposers.push(() => {
753
+ node.removeEventListener('click', onClick)
754
+ node.removeEventListener('contextmenu', onContextMenu)
755
+ node.removeEventListener('mouseenter', onMouseEnter)
756
+ node.removeEventListener('mousemove', onMouseMove)
757
+ node.removeEventListener('mouseleave', onMouseLeave)
758
+ node.removeEventListener('pointerdown', onPointerDown)
759
+ })
760
+ }
761
+
762
+ startTaskDrag(node, task, event) {
763
+ if (!this.isTaskDraggable(task)) return
764
+ if (event.button !== undefined && event.button !== 0) return
765
+ if (event.target && event.target.closest && event.target.closest('[data-vg-no-drag]')) return
766
+
767
+ const sourceTask = this.findSourceTask(task)
768
+ if (!sourceTask) return
769
+
770
+ const startTime = toTime(this.taskStart(sourceTask))
771
+ const endTime = toTime(this.taskEnd(sourceTask))
772
+ if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) return
773
+
774
+ const startPayload = this.createTaskPayload(task, {
775
+ event,
776
+ sourceTask,
777
+ startDate: new Date(startTime),
778
+ endDate: new Date(endTime),
779
+ originalStartDate: new Date(startTime),
780
+ originalEndDate: new Date(endTime)
781
+ })
782
+ if (this.callTaskCallback('onDragStart', task, event, startPayload) === false) return
783
+
784
+ this.hideTaskTooltip()
785
+ node.classList.add('vg-task-fo--dragging')
786
+ if (node.setPointerCapture && event.pointerId !== undefined) {
787
+ node.setPointerCapture(event.pointerId)
788
+ }
789
+
790
+ const drag = {
791
+ node,
792
+ task,
793
+ sourceTask,
794
+ pointerId: event.pointerId,
795
+ startClientX: event.clientX,
796
+ originalStartTime: startTime,
797
+ originalEndTime: endTime,
798
+ nextStartTime: startTime,
799
+ nextEndTime: endTime,
800
+ moved: false
801
+ }
802
+ this.activeDrag = drag
803
+
804
+ const onMove = moveEvent => this.moveTaskDrag(moveEvent)
805
+ const onUp = upEvent => {
806
+ document.removeEventListener('pointermove', onMove)
807
+ document.removeEventListener('pointerup', onUp)
808
+ document.removeEventListener('pointercancel', onUp)
809
+ this.endTaskDrag(upEvent)
810
+ }
811
+ document.addEventListener('pointermove', onMove)
812
+ document.addEventListener('pointerup', onUp)
813
+ document.addEventListener('pointercancel', onUp)
814
+ this.disposers.push(() => {
815
+ document.removeEventListener('pointermove', onMove)
816
+ document.removeEventListener('pointerup', onUp)
817
+ document.removeEventListener('pointercancel', onUp)
818
+ })
819
+
820
+ event.preventDefault()
821
+ event.stopPropagation()
822
+ }
823
+
824
+ moveTaskDrag(event) {
825
+ const drag = this.activeDrag
826
+ if (!drag) return
827
+ if (drag.pointerId !== undefined && event.pointerId !== undefined && drag.pointerId !== event.pointerId) return
828
+
829
+ const deltaX = event.clientX - drag.startClientX
830
+ if (Math.abs(deltaX) < 2 && !drag.moved) return
831
+
832
+ const deltaMs = this.snapDragDelta(deltaX / this.pxPerMs)
833
+ const nextRange = this.clampDragRange(drag.originalStartTime + deltaMs, drag.originalEndTime + deltaMs)
834
+ drag.nextStartTime = nextRange.start
835
+ drag.nextEndTime = nextRange.end
836
+ drag.moved = true
837
+ drag.node.setAttribute('x', this.timeToX(nextRange.start))
838
+
839
+ this.callTaskCallback('onDrag', drag.task, event, this.createTaskPayload(drag.task, {
840
+ event,
841
+ sourceTask: drag.sourceTask,
842
+ startDate: new Date(nextRange.start),
843
+ endDate: new Date(nextRange.end),
844
+ originalStartDate: new Date(drag.originalStartTime),
845
+ originalEndDate: new Date(drag.originalEndTime)
846
+ }))
847
+ event.preventDefault()
848
+ }
849
+
850
+ endTaskDrag(event) {
851
+ const drag = this.activeDrag
852
+ if (!drag) return
853
+ this.activeDrag = null
854
+ drag.node.classList.remove('vg-task-fo--dragging')
855
+ if (drag.node.releasePointerCapture && drag.pointerId !== undefined) {
856
+ try {
857
+ drag.node.releasePointerCapture(drag.pointerId)
858
+ } catch (error) {}
859
+ }
860
+
861
+ if (drag.moved) {
862
+ this.updateTaskTime(drag.sourceTask, drag.nextStartTime, drag.nextEndTime)
863
+ this.suppressClickTaskKey = this.taskKey(drag.task)
864
+ this.suppressClickUntil = Date.now() + 300
865
+ this.callTaskCallback('onDragEnd', drag.task, event, this.createTaskPayload(drag.task, {
866
+ event,
867
+ sourceTask: drag.sourceTask,
868
+ startDate: new Date(drag.nextStartTime),
869
+ endDate: new Date(drag.nextEndTime),
870
+ originalStartDate: new Date(drag.originalStartTime),
871
+ originalEndDate: new Date(drag.originalEndTime)
872
+ }))
873
+ this.render()
874
+ }
875
+ }
876
+
877
+ showTaskTooltip(task, event) {
878
+ const tooltip = this.taskTooltip
879
+ if (!tooltip || tooltip.visible === false) return
880
+
881
+ const payload = this.createTaskPayload(task, { event })
882
+ const content = this.resolveContent(tooltip.customLayout, payload) || this.renderDefaultTaskTooltip(payload)
883
+ if (!content) return
884
+
885
+ this.hideTaskTooltip()
886
+ const node = el('div', `vg-tooltip${tooltip.className ? ` ${tooltip.className}` : ''}`)
887
+ node.append(content)
888
+ document.body.append(node)
889
+ this.tooltipEl = node
890
+ this.positionTaskTooltip(event)
891
+ }
892
+
893
+ positionTaskTooltip(event) {
894
+ if (!this.tooltipEl || !event) return
895
+ const tooltip = this.taskTooltip || {}
896
+ const offsetX = tooltip.offsetX === undefined ? 12 : Number(tooltip.offsetX)
897
+ const offsetY = tooltip.offsetY === undefined ? 12 : Number(tooltip.offsetY)
898
+ const rect = this.tooltipEl.getBoundingClientRect()
899
+ const maxLeft = window.innerWidth - rect.width - 8
900
+ const maxTop = window.innerHeight - rect.height - 8
901
+ const left = Math.max(8, Math.min(maxLeft, event.clientX + offsetX))
902
+ const top = Math.max(8, Math.min(maxTop, event.clientY + offsetY))
903
+ this.tooltipEl.style.left = `${left}px`
904
+ this.tooltipEl.style.top = `${top}px`
905
+ }
906
+
907
+ hideTaskTooltip() {
908
+ if (!this.tooltipEl) return
909
+ this.tooltipEl.remove()
910
+ this.tooltipEl = null
911
+ }
912
+
913
+ renderDefaultTaskTooltip(payload) {
914
+ const root = el('div', 'vg-tooltip-default')
915
+ const title = el('div', 'vg-tooltip-title')
916
+ title.textContent = this.taskLabel(payload.task, this.taskBar.labelText)
917
+ const time = el('div', 'vg-tooltip-time')
918
+ time.textContent = `${formatDateTime(payload.startDate)} - ${formatDateTime(payload.endDate)}`
919
+ root.append(title, time)
920
+ const subtitle = this.taskLabel(payload.task, this.taskBar.subLabelText)
921
+ if (subtitle) {
922
+ const meta = el('div', 'vg-tooltip-meta')
923
+ meta.textContent = subtitle
924
+ root.append(meta)
925
+ }
926
+ return root
927
+ }
928
+
929
+ createTaskPayload(task, extra = {}) {
930
+ const row = extra.row || this.rowById[task.__rowId]
931
+ const sourceTask = extra.sourceTask || this.findSourceTask(task) || task
932
+ const startTime = extra.startDate ? extra.startDate.getTime() : toTime(this.taskStart(sourceTask))
933
+ const endTime = extra.endDate ? extra.endDate.getTime() : toTime(this.taskEnd(sourceTask))
934
+ const width = extra.width === undefined ? this.durationWidth(this.taskStart(task), this.taskEnd(task)) : extra.width
935
+ const height = extra.height === undefined ? this.taskRenderHeight(task) : extra.height
936
+
937
+ return {
938
+ taskRecord: sourceTask,
939
+ task,
940
+ sourceTask,
941
+ rowRecord: row,
942
+ row,
943
+ taskKey: this.taskKey(task),
944
+ rowKey: task.__rowId,
945
+ x: extra.x === undefined ? this.timeToX(this.taskStart(task)) : extra.x,
946
+ y: extra.y === undefined ? this.taskY(task) : extra.y,
947
+ width,
948
+ height,
949
+ startDate: new Date(startTime),
950
+ endDate: new Date(endTime),
951
+ originalStartDate: extra.originalStartDate,
952
+ originalEndDate: extra.originalEndDate,
953
+ progress: this.taskProgress(task),
954
+ event: extra.event,
955
+ ganttInstance: this,
956
+ gantt: this
957
+ }
958
+ }
959
+
960
+ callTaskCallback(name, task, event, payload) {
961
+ const callback = this.taskBar[name]
962
+ if (typeof callback !== 'function') return undefined
963
+ return callback(payload || this.createTaskPayload(task, { event }))
964
+ }
965
+
966
+ isTaskDraggable(task) {
967
+ if (task.draggable !== undefined) return task.draggable !== false
968
+ if (task.parentAggregate) return false
969
+ const draggable = this.taskBar.draggable
970
+ if (typeof draggable === 'function') {
971
+ return draggable(this.createTaskPayload(task)) !== false
972
+ }
973
+ return draggable !== false
974
+ }
975
+
976
+ findSourceTask(task) {
977
+ const rowKey = task.__rowId
978
+ const taskKey = this.taskKey(task)
979
+ if (!rowKey || !taskKey) return null
980
+ const record = this.findRecordByKey(rowKey)
981
+ const tasks = record && Array.isArray(record[this.taskBar.tasksField]) ? record[this.taskBar.tasksField] : []
982
+ return tasks.find(item => this.taskKey(item) === taskKey) || null
983
+ }
984
+
985
+ findRecordByKey(recordKey, records = this.options.records) {
986
+ for (const record of records || []) {
987
+ if (this.recordKey(record) === recordKey) return record
988
+ if (record.children) {
989
+ const found = this.findRecordByKey(recordKey, record.children)
990
+ if (found) return found
991
+ }
992
+ }
993
+ return null
994
+ }
995
+
996
+ snapDragDelta(deltaMs) {
997
+ const step = Number(this.taskBar.dragStep || 0)
998
+ if (!step) return deltaMs
999
+ return Math.round(deltaMs / step) * step
1000
+ }
1001
+
1002
+ clampDragRange(startTime, endTime) {
1003
+ const duration = endTime - startTime
1004
+ if (duration >= this.rangeMs) {
1005
+ return { start: this.startTime, end: this.endTime }
1006
+ }
1007
+ let start = startTime
1008
+ let end = endTime
1009
+ if (start < this.startTime) {
1010
+ start = this.startTime
1011
+ end = start + duration
1012
+ }
1013
+ if (end > this.endTime) {
1014
+ end = this.endTime
1015
+ start = end - duration
1016
+ }
1017
+ return { start, end }
1018
+ }
1019
+
1020
+ updateTaskTime(task, startTime, endTime) {
1021
+ const startField = this.taskBar.startDateField
1022
+ const endField = this.taskBar.endDateField
1023
+ task[startField] = this.createTimeValueLike(task[startField], startTime)
1024
+ task[endField] = this.createTimeValueLike(task[endField], endTime)
1025
+ }
1026
+
1027
+ createTimeValueLike(source, time) {
1028
+ if (source instanceof Date) return new Date(time)
1029
+ if (typeof source === 'number') return time
1030
+ return formatLocalDateTime(new Date(time))
1031
+ }
1032
+
1033
+ renderDefaultTask(task) {
1034
+ const status = this.taskStatus(task)
1035
+ const root = el('div', `vg-task vg-task--${status || 'normal'}`)
1036
+ const style = this.taskStyle(task)
1037
+ this.applyTaskStyle(root, style, task)
1038
+
1039
+ const title = el('div', 'vg-task-title')
1040
+ title.textContent = this.taskLabel(task, this.taskBar.labelText)
1041
+ const meta = el('div', 'vg-task-meta')
1042
+ meta.textContent = this.taskLabel(task, this.taskBar.subLabelText)
1043
+ root.append(title, meta)
1044
+
1045
+ const progressValue = this.taskProgress(task)
1046
+ if (progressValue !== undefined) {
1047
+ const progress = el('div', 'vg-task-progress')
1048
+ const bar = el('i')
1049
+ bar.style.width = `${progressValue}%`
1050
+ if (style.completedBarColor) bar.style.background = style.completedBarColor
1051
+ progress.append(bar)
1052
+ root.append(progress)
1053
+ }
1054
+
1055
+ if (task.locked) {
1056
+ const lock = el('span', 'vg-task-lock')
1057
+ lock.textContent = '▣'
1058
+ root.append(lock)
1059
+ }
1060
+
1061
+ return root
1062
+ }
1063
+
1064
+ applyTaskStyle(node, style, task) {
1065
+ if (!style) return
1066
+ const progressValue = this.taskProgress(task)
1067
+ const background = progressValue >= 100 && style.completedBarColor ? style.completedBarColor : style.barColor
1068
+ if (background) node.style.background = background
1069
+ if (style.borderColor) node.style.borderColor = style.borderColor
1070
+ if (style.borderLineWidth !== undefined) node.style.borderWidth = `${style.borderLineWidth}px`
1071
+ if (style.borderWidth !== undefined) node.style.borderWidth = `${style.borderWidth}px`
1072
+ if (style.cornerRadius !== undefined) node.style.borderRadius = `${style.cornerRadius}px`
1073
+ }
1074
+
1075
+ rect(x, y, width, height, fill) {
1076
+ const rect = svgEl('rect')
1077
+ attrs(rect, { x, y, width, height, fill })
1078
+ return rect
1079
+ }
1080
+
1081
+ line(x1, y1, x2, y2, stroke) {
1082
+ const line = svgEl('line')
1083
+ attrs(line, { x1, y1, x2, y2, stroke })
1084
+ return line
1085
+ }
1086
+
1087
+ circle(cx, cy, r, fill) {
1088
+ const circle = svgEl('circle')
1089
+ attrs(circle, { cx, cy, r, fill })
1090
+ return circle
1091
+ }
1092
+
1093
+ path(d, stroke) {
1094
+ const path = svgEl('path', 'vg-link-path')
1095
+ attrs(path, { d, stroke })
1096
+ return path
1097
+ }
1098
+
1099
+ arrow(x, y, direction, fill) {
1100
+ const arrow = svgEl('path', 'vg-link-arrow')
1101
+ const d = direction >= 0
1102
+ ? `M ${x} ${y} l -8 -4 v 8 Z`
1103
+ : `M ${x} ${y} l 8 -4 v 8 Z`
1104
+ attrs(arrow, { d, fill })
1105
+ return arrow
1106
+ }
1107
+
1108
+ applyLineStyle(line, style = {}) {
1109
+ if (style.lineWidth !== undefined) line.setAttribute('stroke-width', style.lineWidth)
1110
+ if (style.lineDash) line.setAttribute('stroke-dasharray', style.lineDash.join(' '))
1111
+ }
1112
+
1113
+ resolveStyle(style, payload) {
1114
+ if (typeof style === 'function') return style(payload) || {}
1115
+ return style || {}
1116
+ }
1117
+
1118
+ resolveContent(renderer, payload) {
1119
+ if (typeof renderer !== 'function') return null
1120
+ const result = renderer(payload)
1121
+ if (result === undefined || result === null) return null
1122
+ if (result instanceof Node) return result
1123
+ if (result.rootContainer instanceof Node) return result.rootContainer
1124
+ const template = document.createElement('template')
1125
+ template.innerHTML = String(result).trim()
1126
+ return template.content
1127
+ }
1128
+
1129
+ seedExpandedRows(records, applyExplicit = false) {
1130
+ records.forEach(record => {
1131
+ if (record.children) {
1132
+ const key = this.recordKey(record)
1133
+ if (applyExplicit && record.expanded !== undefined) {
1134
+ this.expandedRows[key] = record.expanded !== false
1135
+ } else if (this.expandedRows[key] === undefined) {
1136
+ this.expandedRows[key] = record.expanded !== false
1137
+ }
1138
+ this.seedExpandedRows(record.children, applyExplicit)
1139
+ }
1140
+ })
1141
+ }
1142
+
1143
+ isExpanded(row) {
1144
+ return row.children ? this.expandedRows[this.recordKey(row)] !== false : false
1145
+ }
1146
+
1147
+ toggleRow(row) {
1148
+ if (!row.children) return
1149
+ const key = this.recordKey(row)
1150
+ this.expandedRows[key] = !this.isExpanded(row)
1151
+ this.render()
1152
+ }
1153
+
1154
+ recordKey(record) {
1155
+ return record.__recordKey || record[this.options.recordKeyField]
1156
+ }
1157
+
1158
+ taskKey(task) {
1159
+ return task[this.options.taskKeyField]
1160
+ }
1161
+
1162
+ taskStart(task) {
1163
+ return task[this.taskBar.startDateField]
1164
+ }
1165
+
1166
+ taskEnd(task) {
1167
+ return task[this.taskBar.endDateField]
1168
+ }
1169
+
1170
+ taskProgress(task) {
1171
+ return task[this.taskBar.progressField]
1172
+ }
1173
+
1174
+ taskLane(task) {
1175
+ return task[this.taskBar.laneField]
1176
+ }
1177
+
1178
+ taskStatus(task) {
1179
+ return task[this.taskBar.statusField]
1180
+ }
1181
+
1182
+ taskLabel(task, label) {
1183
+ if (!label) return ''
1184
+ if (typeof label === 'function') return label(task)
1185
+ return task[label] === undefined ? String(label) : String(task[label])
1186
+ }
1187
+
1188
+ taskStyle(task) {
1189
+ const style = task.parentAggregate ? this.taskBar.projectStyle : this.taskBar.barStyle
1190
+ return this.resolveStyle(style, {
1191
+ taskRecord: task,
1192
+ index: this.renderTasks.findIndex(item => this.taskKey(item) === this.taskKey(task)),
1193
+ startDate: new Date(toTime(this.taskStart(task))),
1194
+ endDate: new Date(toTime(this.taskEnd(task))),
1195
+ ganttInstance: this
1196
+ })
1197
+ }
1198
+
1199
+ isTaskDimmed(task) {
1200
+ if (!this.dependency.highlightConnected) return false
1201
+ const key = this.taskKey(task)
1202
+ if (!key) return false
1203
+ if (!this.visibleLinks.length) return false
1204
+ return !this.connectedTaskKeys[key]
1205
+ }
1206
+
1207
+ activateTaskLinkGroup(task) {
1208
+ const taskKey = this.taskKey(task)
1209
+ const groupKey = this.firstLinkGroupKeyByTask(taskKey)
1210
+ const nextTaskKey = groupKey ? taskKey : null
1211
+ const changed = this.activeLinkTaskKey !== nextTaskKey || this.activeLinkGroupKey !== groupKey
1212
+ this.activeLinkTaskKey = nextTaskKey
1213
+ this.activeLinkGroupKey = groupKey
1214
+ return changed
1215
+ }
1216
+
1217
+ clearActiveLinkGroup() {
1218
+ if (!this.activeLinkTaskKey && !this.activeLinkGroupKey) return false
1219
+ this.activeLinkTaskKey = null
1220
+ this.activeLinkGroupKey = null
1221
+ return true
1222
+ }
1223
+
1224
+ timeToX(value) {
1225
+ return (toTime(value) - this.startTime) * this.pxPerMs
1226
+ }
1227
+
1228
+ durationWidth(start, end) {
1229
+ return Math.max(this.taskMinWidth, this.timeToX(end) - this.timeToX(start))
1230
+ }
1231
+
1232
+ rowTop(recordKey) {
1233
+ let top = 0
1234
+ for (const row of this.visibleRows) {
1235
+ if (this.recordKey(row) === recordKey) return top
1236
+ top += row.height || this.options.rowHeight
1237
+ }
1238
+ return 0
1239
+ }
1240
+
1241
+ taskY(task) {
1242
+ return this.rowTop(task.__rowId) + this.taskOffsetY(task)
1243
+ }
1244
+
1245
+ taskOffsetY(task) {
1246
+ if (task.parentAggregate && task.offsetY !== undefined) return task.offsetY
1247
+ const lane = this.taskLane(task)
1248
+ if (lane && this.laneByKey[lane]) return this.laneByKey[lane].offset
1249
+ return task.offsetY === undefined ? 10 : task.offsetY
1250
+ }
1251
+
1252
+ taskRenderHeight(task) {
1253
+ if (task.parentAggregate && task.height !== undefined) return task.height
1254
+ const lane = this.taskLane(task)
1255
+ if (lane && this.laneByKey[lane]) return this.laneByKey[lane].height
1256
+ if (task.height) return task.height
1257
+ const style = this.taskStyle(task)
1258
+ return style.width || this.options.taskHeight
1259
+ }
1260
+
1261
+ createUnits(scale, scaleIndex) {
1262
+ const units = []
1263
+ const unit = scale.unit || 'hour'
1264
+ const step = scale.step || 1
1265
+ let cursor = this.floorDate(new Date(this.startTime), unit, scale.startOfWeek)
1266
+ let dateIndex = 0
1267
+ while (cursor.getTime() < this.endTime) {
1268
+ const unitStart = Math.max(cursor.getTime(), this.startTime)
1269
+ const next = this.addUnit(cursor, unit, step)
1270
+ const unitEnd = Math.min(next.getTime(), this.endTime)
1271
+ if (unitEnd > unitStart) {
1272
+ const info = {
1273
+ type: unit,
1274
+ unit,
1275
+ step,
1276
+ scaleIndex,
1277
+ dateIndex,
1278
+ key: `${unit}-${cursor.toISOString()}`,
1279
+ title: this.formatUnitLabel(cursor, unit),
1280
+ startDate: new Date(unitStart),
1281
+ endDate: new Date(unitEnd),
1282
+ days: Math.max(1, Math.ceil((unitEnd - unitStart) / (24 * HOUR))),
1283
+ x: this.timeToX(unitStart),
1284
+ width: Math.max(1, this.timeToX(unitEnd) - this.timeToX(unitStart))
1285
+ }
1286
+ info.label = this.formatTimelineLabel(scale, info)
1287
+ units.push(info)
1288
+ dateIndex += 1
1289
+ }
1290
+ cursor = next
1291
+ }
1292
+ return units
1293
+ }
1294
+
1295
+ formatTimelineLabel(scale, dateInfo) {
1296
+ if (typeof scale.format === 'function') {
1297
+ return scale.format(dateInfo)
1298
+ }
1299
+ return this.formatUnitLabel(dateInfo.startDate, scale.unit)
1300
+ }
1301
+
1302
+ floorDate(date, unit, startOfWeek = 'monday') {
1303
+ const next = new Date(date)
1304
+ next.setSeconds(0, 0)
1305
+ if (unit !== 'minute') next.setMinutes(0)
1306
+ if (!['minute', 'hour'].includes(unit)) next.setHours(0)
1307
+ if (unit === 'week') {
1308
+ const day = next.getDay() || 7
1309
+ const offset = startOfWeek === 'sunday' ? next.getDay() : day - 1
1310
+ next.setDate(next.getDate() - offset)
1311
+ }
1312
+ if (unit === 'month') next.setDate(1)
1313
+ if (unit === 'year') {
1314
+ next.setMonth(0)
1315
+ next.setDate(1)
1316
+ }
1317
+ return next
1318
+ }
1319
+
1320
+ addUnit(date, unit, step = 1) {
1321
+ const next = new Date(date)
1322
+ if (unit === 'minute') next.setMinutes(next.getMinutes() + step)
1323
+ else if (unit === 'hour') next.setHours(next.getHours() + step)
1324
+ else if (unit === 'day') next.setDate(next.getDate() + step)
1325
+ else if (unit === 'week') next.setDate(next.getDate() + 7 * step)
1326
+ else if (unit === 'month') next.setMonth(next.getMonth() + step)
1327
+ else if (unit === 'year') next.setFullYear(next.getFullYear() + step)
1328
+ return next
1329
+ }
1330
+
1331
+ unitToMs(unit) {
1332
+ if (unit === 'year') return 365 * 24 * HOUR
1333
+ if (unit === 'month') return 30 * 24 * HOUR
1334
+ if (unit === 'week') return 7 * 24 * HOUR
1335
+ if (unit === 'day') return 24 * HOUR
1336
+ if (unit === 'minute') return 60 * 1000
1337
+ return HOUR
1338
+ }
1339
+
1340
+ formatUnitLabel(date, unit) {
1341
+ const month = String(date.getMonth() + 1).padStart(2, '0')
1342
+ const day = String(date.getDate()).padStart(2, '0')
1343
+ if (unit === 'year') return `${date.getFullYear()}`
1344
+ if (unit === 'month') return `${date.getFullYear()}-${month}`
1345
+ if (unit === 'week') return `${month}-${day}周`
1346
+ if (unit === 'day') return `${month}-${day}`
1347
+ if (unit === 'minute') return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
1348
+ return String(date.getHours()).padStart(2, '0')
1349
+ }
1350
+
1351
+ scaleRowHeight(scale) {
1352
+ return scale.rowHeight || this.options.headerRowHeight
1353
+ }
1354
+
1355
+ timelineCellStyle(scale) {
1356
+ const style = {
1357
+ ...(this.timelineHeader.style || {}),
1358
+ ...(scale.style || {})
1359
+ }
1360
+ if (!style.backgroundColor && this.timelineHeader.backgroundColor) {
1361
+ style.backgroundColor = this.timelineHeader.backgroundColor
1362
+ }
1363
+ return style
1364
+ }
1365
+
1366
+ timelineCustomCellStyle(scale) {
1367
+ return {
1368
+ ...(this.timelineHeader.style || {}),
1369
+ ...(scale.style || {})
1370
+ }
1371
+ }
1372
+
1373
+ getDescendantRecordKeys(row) {
1374
+ const ids = []
1375
+ const walk = children => {
1376
+ children.forEach(child => {
1377
+ ids.push(this.recordKey(child))
1378
+ if (child.children) walk(child.children)
1379
+ })
1380
+ }
1381
+ walk(row.children || [])
1382
+ return ids
1383
+ }
1384
+
1385
+ overlapsRange(start, end) {
1386
+ return toTime(end) > this.startTime && toTime(start) < this.endTime
1387
+ }
1388
+
1389
+ isPrimaryTask(task) {
1390
+ if (task.logistics) return false
1391
+ const lane = this.taskLane(task)
1392
+ if (!lane) return true
1393
+ return lane === 'plan' || String(lane).startsWith('plan-')
1394
+ }
1395
+
1396
+ isExpandedParentRow(recordKey) {
1397
+ const row = this.rowById[recordKey]
1398
+ return row && row.children && this.isExpanded(row)
1399
+ }
1400
+
1401
+ aggregateWorkStatus(tasks) {
1402
+ if (tasks.some(task => task.workStatus === 'severe-delay')) return 'severe-delay'
1403
+ if (tasks.some(task => task.workStatus === 'slight-delay')) return 'slight-delay'
1404
+ if (tasks.every(task => task.workStatus === 'not-started')) return 'not-started'
1405
+ return 'progress-normal'
1406
+ }
1407
+
1408
+ loadColor(load) {
1409
+ if (load >= 90) return '#ff4d5a'
1410
+ if (load >= 70) return '#f7a600'
1411
+ return '#43c51a'
1412
+ }
1413
+
1414
+ get taskListTable() {
1415
+ return this.options.taskListTable || {}
1416
+ }
1417
+
1418
+ get timelineHeader() {
1419
+ return this.options.timelineHeader || {}
1420
+ }
1421
+
1422
+ get taskBar() {
1423
+ return this.options.taskBar || {}
1424
+ }
1425
+
1426
+ get taskTooltip() {
1427
+ const tooltip = this.taskBar.tooltip
1428
+ if (tooltip === false || tooltip === undefined || tooltip === null) return null
1429
+ if (tooltip === true) return DEFAULT_TASK_TOOLTIP
1430
+ return {
1431
+ ...DEFAULT_TASK_TOOLTIP,
1432
+ ...tooltip
1433
+ }
1434
+ }
1435
+
1436
+ get dependency() {
1437
+ return this.options.dependency || {}
1438
+ }
1439
+
1440
+ get grid() {
1441
+ return this.options.grid || {}
1442
+ }
1443
+
1444
+ get tableWidth() {
1445
+ if (typeof this.taskListTable.tableWidth === 'number') return this.taskListTable.tableWidth
1446
+ return this.tableColumns.reduce((total, column) => total + this.columnWidth(column), 0)
1447
+ }
1448
+
1449
+ get tableColumns() {
1450
+ return this.sourceTableColumns.map(column => ({
1451
+ width: 120,
1452
+ ...column
1453
+ }))
1454
+ }
1455
+
1456
+ get sourceTableColumns() {
1457
+ return this.taskListTable.columns && this.taskListTable.columns.length
1458
+ ? this.taskListTable.columns
1459
+ : DEFAULT_OPTIONS.taskListTable.columns
1460
+ }
1461
+
1462
+ get tableGridTemplateColumns() {
1463
+ return this.tableColumns.map((column, index) => {
1464
+ const width = this.columnWidth(column)
1465
+ if (index === this.tableColumns.length - 1) return `minmax(${width}px, 1fr)`
1466
+ return `${width}px`
1467
+ }).join(' ')
1468
+ }
1469
+
1470
+ columnWidth(column) {
1471
+ return Number(column.width || column.minWidth || 120)
1472
+ }
1473
+
1474
+ get startTime() {
1475
+ const value = this.options.minDate || this.derivedStartTime
1476
+ return toTime(value)
1477
+ }
1478
+
1479
+ get endTime() {
1480
+ const value = this.options.maxDate || this.derivedEndTime
1481
+ return toTime(value)
1482
+ }
1483
+
1484
+ get derivedStartTime() {
1485
+ const values = this.allTasks.map(task => toTime(this.taskStart(task))).filter(Number.isFinite)
1486
+ return values.length ? Math.min(...values) : Date.now()
1487
+ }
1488
+
1489
+ get derivedEndTime() {
1490
+ const values = this.allTasks.map(task => toTime(this.taskEnd(task))).filter(Number.isFinite)
1491
+ return values.length ? Math.max(...values) : this.derivedStartTime + 24 * HOUR
1492
+ }
1493
+
1494
+ get headerHeight() {
1495
+ return this.timelineScales.reduce((total, scale) => total + this.scaleRowHeight(scale), 0)
1496
+ }
1497
+
1498
+ get bodyHeight() {
1499
+ return this.visibleRows.reduce((height, row) => height + (row.height || this.options.rowHeight), 0)
1500
+ }
1501
+
1502
+ get rangeMs() {
1503
+ return Math.max(1, this.endTime - this.startTime)
1504
+ }
1505
+
1506
+ get baseTimelineScale() {
1507
+ const scales = this.timelineScales
1508
+ return scales[scales.length - 1] || DEFAULT_OPTIONS.timelineHeader.scales[1]
1509
+ }
1510
+
1511
+ get scaleMs() {
1512
+ return this.unitToMs(this.baseTimelineScale.unit) * (this.baseTimelineScale.step || 1)
1513
+ }
1514
+
1515
+ get scalePx() {
1516
+ return this.baseTimelineScale.colWidth || this.timelineHeader.colWidth || DEFAULT_OPTIONS.timelineHeader.colWidth
1517
+ }
1518
+
1519
+ get pxPerMs() {
1520
+ return this.scalePx / this.scaleMs
1521
+ }
1522
+
1523
+ get chartWidth() {
1524
+ return Math.max(1, Math.ceil((this.rangeMs / this.scaleMs) * this.scalePx))
1525
+ }
1526
+
1527
+ get timelineScales() {
1528
+ return (this.timelineHeader.scales || []).filter(scale => scale.visible !== false)
1529
+ }
1530
+
1531
+ get timelineUnitsByScale() {
1532
+ return this.timelineScales.map((scale, index) => this.createUnits(scale, index))
1533
+ }
1534
+
1535
+ get majorUnits() {
1536
+ return this.timelineUnitsByScale[0] || []
1537
+ }
1538
+
1539
+ get baseUnits() {
1540
+ const units = this.timelineUnitsByScale
1541
+ return units[units.length - 1] || []
1542
+ }
1543
+
1544
+ get verticalLines() {
1545
+ return this.baseUnits.map(unit => ({ key: `line-${unit.key}`, x: unit.x, startDate: unit.startDate }))
1546
+ }
1547
+
1548
+ get rowLines() {
1549
+ let top = 0
1550
+ return this.visibleRows.map(row => {
1551
+ top += row.height || this.options.rowHeight
1552
+ return { key: this.recordKey(row), y: top }
1553
+ })
1554
+ }
1555
+
1556
+ get backgroundShades() {
1557
+ const fill = this.grid.alternatingBackgroundColor
1558
+ if (!fill) return []
1559
+ return this.majorUnits.filter((unit, index) => index % 2 === 0).map(unit => ({
1560
+ key: `shade-${unit.key}`,
1561
+ x: unit.x,
1562
+ width: unit.width,
1563
+ fill
1564
+ }))
1565
+ }
1566
+
1567
+ get flatRows() {
1568
+ const rows = []
1569
+ const walk = (items, level = 0) => {
1570
+ items.forEach(record => {
1571
+ rows.push({ ...record, __recordKey: this.recordKey(record), level })
1572
+ if (record.children) walk(record.children, level + 1)
1573
+ })
1574
+ }
1575
+ walk(this.options.records)
1576
+ return rows
1577
+ }
1578
+
1579
+ get visibleRows() {
1580
+ const rows = []
1581
+ const walk = (items, level = 0) => {
1582
+ items.forEach(record => {
1583
+ const normalized = { ...record, __recordKey: this.recordKey(record), level }
1584
+ rows.push(normalized)
1585
+ if (record.children && this.isExpanded(normalized)) {
1586
+ walk(record.children, level + 1)
1587
+ }
1588
+ })
1589
+ }
1590
+ walk(this.options.records)
1591
+ return rows
1592
+ }
1593
+
1594
+ get visibleRowIds() {
1595
+ return this.visibleRows.reduce((ids, row) => {
1596
+ ids[this.recordKey(row)] = true
1597
+ return ids
1598
+ }, {})
1599
+ }
1600
+
1601
+ get rowById() {
1602
+ return this.flatRows.reduce((map, row) => {
1603
+ map[this.recordKey(row)] = row
1604
+ return map
1605
+ }, {})
1606
+ }
1607
+
1608
+ get laneByKey() {
1609
+ return (this.taskBar.lanes || []).reduce((map, lane) => {
1610
+ map[lane.key] = lane
1611
+ return map
1612
+ }, {})
1613
+ }
1614
+
1615
+ get allTasks() {
1616
+ const tasks = []
1617
+ const tasksField = this.taskBar.tasksField
1618
+ const walk = records => {
1619
+ records.forEach(record => {
1620
+ const recordKey = this.recordKey(record)
1621
+ const rowTasks = Array.isArray(record[tasksField]) ? record[tasksField] : []
1622
+ rowTasks.forEach(task => {
1623
+ tasks.push({
1624
+ ...task,
1625
+ __rowId: recordKey
1626
+ })
1627
+ })
1628
+ if (record.children) walk(record.children)
1629
+ })
1630
+ }
1631
+ walk(this.options.records)
1632
+ return tasks
1633
+ }
1634
+
1635
+ get renderTasks() {
1636
+ return [...this.parentTimelineTasks, ...this.visibleTasks]
1637
+ }
1638
+
1639
+ get stripedTasks() {
1640
+ return this.renderTasks.filter(task => task.striped)
1641
+ }
1642
+
1643
+ get visibleTasks() {
1644
+ return this.allTasks.filter(task => {
1645
+ const row = this.rowById[task.__rowId]
1646
+ return row &&
1647
+ !row.children &&
1648
+ this.visibleRowIds[task.__rowId] &&
1649
+ this.overlapsRange(this.taskStart(task), this.taskEnd(task))
1650
+ })
1651
+ }
1652
+
1653
+ get parentTimelineTasks() {
1654
+ const tasks = []
1655
+ this.visibleRows.forEach(row => {
1656
+ if (!row.children || this.isExpanded(row)) return
1657
+ const descendantIds = this.getDescendantRecordKeys(row)
1658
+ const childTasks = this.allTasks.filter(task => {
1659
+ return descendantIds.includes(task.__rowId) &&
1660
+ this.isPrimaryTask(task) &&
1661
+ this.overlapsRange(this.taskStart(task), this.taskEnd(task))
1662
+ })
1663
+ if (!childTasks.length) return
1664
+
1665
+ const start = Math.min(...childTasks.map(task => toTime(this.taskStart(task))))
1666
+ const end = Math.max(...childTasks.map(task => toTime(this.taskEnd(task))))
1667
+ const progressTasks = childTasks.filter(task => this.taskProgress(task) !== undefined)
1668
+ const progress = progressTasks.length
1669
+ ? Math.round(progressTasks.reduce((total, task) => total + this.taskProgress(task), 0) / progressTasks.length)
1670
+ : undefined
1671
+
1672
+ tasks.push({
1673
+ [this.options.taskKeyField]: `${this.recordKey(row)}__aggregate`,
1674
+ [this.taskBar.startDateField]: start,
1675
+ [this.taskBar.endDateField]: end,
1676
+ [this.taskBar.progressField]: progress,
1677
+ [this.taskBar.statusField]: this.taskStatus(childTasks[0]),
1678
+ title: row.name,
1679
+ subtitle: `${childTasks.length} 个工单`,
1680
+ workStatus: this.aggregateWorkStatus(childTasks),
1681
+ completed: childTasks.every(task => task.completed),
1682
+ predecessorIncomplete: childTasks.some(task => task.predecessorIncomplete),
1683
+ height: Math.max(28, (row.height || this.options.rowHeight) - 12),
1684
+ offsetY: 6,
1685
+ parentAggregate: true,
1686
+ __rowId: this.recordKey(row)
1687
+ })
1688
+ })
1689
+ return tasks
1690
+ }
1691
+
1692
+ get visibleBackgroundRanges() {
1693
+ return (this.grid.backgroundRanges || []).filter(range => {
1694
+ return toTime(range.endDate) > this.startTime && toTime(range.startDate) < this.endTime
1695
+ })
1696
+ }
1697
+
1698
+ get visibleRowBackgroundRanges() {
1699
+ return (this.grid.rowBackgroundRanges || []).filter(range => {
1700
+ return this.visibleRowIds[range.recordKey] &&
1701
+ !this.isExpandedParentRow(range.recordKey) &&
1702
+ toTime(range.endDate) > this.startTime &&
1703
+ toTime(range.startDate) < this.endTime
1704
+ })
1705
+ }
1706
+
1707
+ get markLines() {
1708
+ if (!this.options.markLine || this.options.markLine === true) return []
1709
+ return Array.isArray(this.options.markLine) ? this.options.markLine : [this.options.markLine]
1710
+ }
1711
+
1712
+ get taskLayoutByKey() {
1713
+ return this.renderTasks.reduce((map, task) => {
1714
+ const key = this.taskKey(task)
1715
+ if (!key) return map
1716
+ const y = this.taskY(task)
1717
+ const height = this.taskRenderHeight(task)
1718
+ map[key] = {
1719
+ task,
1720
+ x: this.timeToX(this.taskStart(task)),
1721
+ y,
1722
+ width: this.durationWidth(this.taskStart(task), this.taskEnd(task)),
1723
+ height,
1724
+ centerY: y + height / 2
1725
+ }
1726
+ return map
1727
+ }, {})
1728
+ }
1729
+
1730
+ get visibleLinks() {
1731
+ return this.activeNormalizedLinks.filter(link => {
1732
+ return this.taskLayoutByKey[link.from] && this.taskLayoutByKey[link.to]
1733
+ })
1734
+ }
1735
+
1736
+ get activeNormalizedLinks() {
1737
+ if (this.activeLinkGroupKey) {
1738
+ return this.normalizedLinks.filter(link => link.__groupKey === this.activeLinkGroupKey)
1739
+ }
1740
+ return this.dependency.showLinks === true ? this.normalizedLinks : []
1741
+ }
1742
+
1743
+ get normalizedLinks() {
1744
+ const links = []
1745
+ ;(this.dependency.links || []).forEach((link, linkIndex) => {
1746
+ const fromKeys = toKeyList(link.from)
1747
+ const toKeys = toKeyList(link.to)
1748
+ const groupKey = link.id === undefined || link.id === null
1749
+ ? `link-${linkIndex}`
1750
+ : String(link.id)
1751
+ fromKeys.forEach(from => {
1752
+ toKeys.forEach(to => {
1753
+ if (from === undefined || from === null || to === undefined || to === null) return
1754
+ links.push({
1755
+ ...link,
1756
+ from,
1757
+ to,
1758
+ __groupKey: groupKey
1759
+ })
1760
+ })
1761
+ })
1762
+ })
1763
+ return links
1764
+ }
1765
+
1766
+ firstLinkGroupKeyByTask(taskKey) {
1767
+ if (taskKey === undefined || taskKey === null) return null
1768
+ const link = this.normalizedLinks.find(item => item.from === taskKey || item.to === taskKey)
1769
+ return link ? link.__groupKey : null
1770
+ }
1771
+
1772
+ get connectedTaskKeys() {
1773
+ return this.visibleLinks.reduce((map, link) => {
1774
+ map[link.from] = true
1775
+ map[link.to] = true
1776
+ return map
1777
+ }, {})
1778
+ }
1779
+
1780
+ get taskMinWidth() {
1781
+ if (typeof this.taskBar.barStyle === 'function') return 4
1782
+ const style = this.resolveStyle(this.taskBar.barStyle, {})
1783
+ return style.minSize || 4
1784
+ }
1785
+ }
1786
+
1787
+ function mergeOptions(base, patch) {
1788
+ const baseTaskBar = base.taskBar || {}
1789
+ const patchTaskBar = patch.taskBar || {}
1790
+ return {
1791
+ ...base,
1792
+ ...patch,
1793
+ taskListTable: {
1794
+ ...(base.taskListTable || {}),
1795
+ ...(patch.taskListTable || {})
1796
+ },
1797
+ timelineHeader: {
1798
+ ...(base.timelineHeader || {}),
1799
+ ...(patch.timelineHeader || {}),
1800
+ scales: patch.timelineHeader && patch.timelineHeader.scales
1801
+ ? patch.timelineHeader.scales
1802
+ : (base.timelineHeader && base.timelineHeader.scales) || []
1803
+ },
1804
+ taskBar: {
1805
+ ...baseTaskBar,
1806
+ ...patchTaskBar,
1807
+ tooltip: mergeNestedOption(baseTaskBar.tooltip, patchTaskBar.tooltip),
1808
+ lanes: patchTaskBar.lanes
1809
+ ? patchTaskBar.lanes
1810
+ : baseTaskBar.lanes || []
1811
+ },
1812
+ dependency: {
1813
+ ...(base.dependency || {}),
1814
+ ...(patch.dependency || {}),
1815
+ links: patch.dependency && patch.dependency.links
1816
+ ? patch.dependency.links
1817
+ : (base.dependency && base.dependency.links) || []
1818
+ },
1819
+ grid: {
1820
+ ...(base.grid || {}),
1821
+ ...(patch.grid || {}),
1822
+ backgroundRanges: patch.grid && patch.grid.backgroundRanges
1823
+ ? patch.grid.backgroundRanges
1824
+ : (base.grid && base.grid.backgroundRanges) || [],
1825
+ rowBackgroundRanges: patch.grid && patch.grid.rowBackgroundRanges
1826
+ ? patch.grid.rowBackgroundRanges
1827
+ : (base.grid && base.grid.rowBackgroundRanges) || []
1828
+ },
1829
+ records: patch.records || base.records || []
1830
+ }
1831
+ }
1832
+
1833
+ function mergeNestedOption(base, patch) {
1834
+ if (patch === undefined) return base
1835
+ if (patch === false || patch === true || patch === null) return patch
1836
+ if (typeof patch === 'object' && !Array.isArray(patch)) {
1837
+ return {
1838
+ ...(typeof base === 'object' && base ? base : {}),
1839
+ ...patch
1840
+ }
1841
+ }
1842
+ return patch
1843
+ }
1844
+
1845
+ function applyTableHeaderStyle(node, style = {}) {
1846
+ if (!style) return
1847
+ if (style.backgroundColor) node.style.background = style.backgroundColor
1848
+ if (style.color) node.style.color = style.color
1849
+ if (style.fontSize) node.style.fontSize = `${style.fontSize}px`
1850
+ if (style.fontWeight) node.style.fontWeight = style.fontWeight
1851
+ }
1852
+
1853
+ function applyTimelineStyle(node, style = {}) {
1854
+ if (style.backgroundColor) node.style.background = style.backgroundColor
1855
+ if (style.color) node.style.color = style.color
1856
+ if (style.fontSize) node.style.fontSize = `${style.fontSize}px`
1857
+ if (style.fontWeight) node.style.fontWeight = style.fontWeight
1858
+ if (style.textAlign) node.style.justifyContent = alignToFlex(style.textAlign)
1859
+ }
1860
+
1861
+ function applyTimelineStyleToContent(content, style = {}) {
1862
+ if (!style || !Object.keys(style).length) return
1863
+ const target = content.nodeType === 11 ? content.firstElementChild : content
1864
+ if (target instanceof HTMLElement) {
1865
+ applyTimelineStyle(target, style)
1866
+ }
1867
+ }
1868
+
1869
+ function alignToFlex(value) {
1870
+ if (value === 'left' || value === 'start') return 'flex-start'
1871
+ if (value === 'right' || value === 'end') return 'flex-end'
1872
+ return 'center'
1873
+ }
1874
+
1875
+ function toKeyList(value) {
1876
+ if (Array.isArray(value)) return value
1877
+ if (value === undefined || value === null) return []
1878
+ return [value]
1879
+ }
1880
+
1881
+ function el(tag, className = '') {
1882
+ const node = document.createElement(tag)
1883
+ if (className) node.className = className
1884
+ return node
1885
+ }
1886
+
1887
+ function svgEl(tag, className = '') {
1888
+ const node = document.createElementNS(SVG_NS, tag)
1889
+ if (className) node.setAttribute('class', className)
1890
+ return node
1891
+ }
1892
+
1893
+ function attrs(node, values) {
1894
+ Object.keys(values).forEach(key => {
1895
+ if (values[key] !== undefined && values[key] !== null) {
1896
+ node.setAttribute(key, values[key])
1897
+ }
1898
+ })
1899
+ }
1900
+
1901
+ function toTime(value) {
1902
+ if (typeof value === 'number') return value
1903
+ if (value instanceof Date) return value.getTime()
1904
+ return new Date(value).getTime()
1905
+ }
1906
+
1907
+ function formatDateTime(date) {
1908
+ const value = date instanceof Date ? date : new Date(date)
1909
+ const month = String(value.getMonth() + 1).padStart(2, '0')
1910
+ const day = String(value.getDate()).padStart(2, '0')
1911
+ const hour = String(value.getHours()).padStart(2, '0')
1912
+ const minute = String(value.getMinutes()).padStart(2, '0')
1913
+ return `${value.getFullYear()}-${month}-${day} ${hour}:${minute}`
1914
+ }
1915
+
1916
+ function formatLocalDateTime(date) {
1917
+ const value = date instanceof Date ? date : new Date(date)
1918
+ const month = String(value.getMonth() + 1).padStart(2, '0')
1919
+ const day = String(value.getDate()).padStart(2, '0')
1920
+ const hour = String(value.getHours()).padStart(2, '0')
1921
+ const minute = String(value.getMinutes()).padStart(2, '0')
1922
+ const second = String(value.getSeconds()).padStart(2, '0')
1923
+ return `${value.getFullYear()}-${month}-${day}T${hour}:${minute}:${second}`
1924
+ }