@operato/gantt 2.0.0-beta.0 → 2.0.0-beta.1

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 (55) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/src/locale.d.ts +1 -0
  3. package/dist/src/locale.js +49 -0
  4. package/dist/src/locale.js.map +1 -0
  5. package/dist/src/ox-gantt-chart.d.ts +39 -0
  6. package/dist/src/ox-gantt-chart.js +707 -0
  7. package/dist/src/ox-gantt-chart.js.map +1 -0
  8. package/dist/src/ox-gantt-resize-splitter.d.ts +13 -0
  9. package/dist/src/ox-gantt-resize-splitter.js +117 -0
  10. package/dist/src/ox-gantt-resize-splitter.js.map +1 -0
  11. package/dist/src/ox-gantt.d.ts +24 -0
  12. package/dist/src/ox-gantt.js +186 -0
  13. package/dist/src/ox-gantt.js.map +1 -0
  14. package/dist/src/types.d.ts +10 -5
  15. package/dist/src/types.js.map +1 -1
  16. package/dist/src/utils.d.ts +3 -0
  17. package/dist/src/utils.js +10 -0
  18. package/dist/src/utils.js.map +1 -0
  19. package/dist/stories/ox-gantt-chart.stories.d.ts +41 -0
  20. package/dist/stories/{ox-lite-gantt.stories.js → ox-gantt-chart.stories.js} +53 -21
  21. package/dist/stories/ox-gantt-chart.stories.js.map +1 -0
  22. package/dist/stories/ox-gantt.stories.d.ts +6 -1
  23. package/dist/stories/ox-gantt.stories.js +286 -15
  24. package/dist/stories/ox-gantt.stories.js.map +1 -1
  25. package/dist/tsconfig.tsbuildinfo +1 -1
  26. package/package.json +12 -4
  27. package/src/locale.ts +51 -0
  28. package/src/ox-gantt-chart.ts +773 -0
  29. package/src/ox-gantt-resize-splitter.ts +137 -0
  30. package/src/ox-gantt.ts +177 -0
  31. package/src/types.ts +12 -6
  32. package/src/utils.ts +13 -0
  33. package/stories/{ox-lite-gantt.stories.ts → ox-gantt-chart.stories.ts} +65 -22
  34. package/stories/ox-gantt.stories.ts +289 -16
  35. package/themes/dark-hc.css +151 -0
  36. package/themes/dark-mc.css +151 -0
  37. package/themes/dark.css +151 -0
  38. package/themes/grist-theme.css +169 -0
  39. package/themes/light-hc.css +151 -0
  40. package/themes/light-mc.css +151 -0
  41. package/themes/light.css +151 -0
  42. package/themes/md-typescale-styles.css +100 -0
  43. package/themes/spacing.css +43 -0
  44. package/themes/state-color.css +6 -0
  45. package/dist/src/gantt.d.ts +0 -28
  46. package/dist/src/gantt.js +0 -178
  47. package/dist/src/gantt.js.map +0 -1
  48. package/dist/src/lite-gantt.d.ts +0 -27
  49. package/dist/src/lite-gantt.js +0 -146
  50. package/dist/src/lite-gantt.js.map +0 -1
  51. package/dist/stories/ox-lite-gantt.stories.d.ts +0 -22
  52. package/dist/stories/ox-lite-gantt.stories.js.map +0 -1
  53. package/src/gantt.ts +0 -191
  54. package/src/lite-gantt.ts +0 -152
  55. package/src/renderer.txt +0 -775
@@ -0,0 +1,773 @@
1
+ import { LitElement, html, css, PropertyValues, PropertyValueMap } from 'lit'
2
+ import { customElement, property, query, state } from 'lit/decorators.js'
3
+ import { styles as MDTypeScaleStyles } from '@material/web/typography/md-typescale-styles'
4
+ import * as d3 from 'd3'
5
+
6
+ import { LEVEL, TaskTag } from './types'
7
+ import { levelDown, levelUp } from './utils'
8
+
9
+ import { ScrollbarStyles } from '@operato/styles'
10
+
11
+ import { Task } from './types.js'
12
+
13
+ type TimeScale = 'year-quarter' | 'year-month' | 'quarter-week' | 'week-day'
14
+
15
+ @customElement('ox-gantt-chart')
16
+ export class OxGanttChart extends LitElement {
17
+ static styles = [
18
+ MDTypeScaleStyles,
19
+ ScrollbarStyles,
20
+ css`
21
+ :host {
22
+ display: flex;
23
+
24
+ overflow: hidden;
25
+ }
26
+
27
+ div[chart-container] {
28
+ display: block;
29
+
30
+ overflow-x: auto;
31
+ overflow-y: clip;
32
+
33
+ position: relative;
34
+ }
35
+
36
+ svg {
37
+ min-width: 100%;
38
+ }
39
+
40
+ svg[header] {
41
+ display: block;
42
+ min-width: 100%;
43
+ box-sizing: border-box;
44
+ border-bottom: 1px solid var(--ox-gantt-border-color, var(--md-sys-color-on-surface));
45
+ height: 40px;
46
+ background-color: var(--ox-gantt-header-background-color, var(--md-sys-color-secondary-container));
47
+
48
+ position: sticky;
49
+ top: 0;
50
+ z-index: 1;
51
+ }
52
+
53
+ .top-axis text,
54
+ .bottom-axis text {
55
+ font-size: 12px;
56
+ }
57
+
58
+ .grid line {
59
+ stroke: #e0e0e0;
60
+ }
61
+
62
+ .grid path {
63
+ stroke-width: 0;
64
+ }
65
+
66
+ .no-select {
67
+ user-select: none;
68
+ }
69
+ `
70
+ ]
71
+
72
+ @property({ type: String, attribute: 'from-date' })
73
+ fromDate: string = new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0] /* beginning of this year */
74
+
75
+ @property({ type: String, attribute: 'to-date' })
76
+ toDate: string = new Date(new Date().getFullYear(), 11, 31).toISOString().split('T')[0] /* end of this year */
77
+
78
+ @property({ type: String, attribute: 'axis-scale' }) axisScale: TimeScale = 'week-day'
79
+ @property({ type: Boolean, attribute: 'extend-grid-lines' }) extendGridLines: boolean = false
80
+ @property({ type: Array }) tasks: Task[] = []
81
+
82
+ @state() baseWidth: number = 30
83
+ @state() level: LEVEL = this.axisScale
84
+
85
+ private fromTime: Date = new Date(this.fromDate!)
86
+ private toTime: Date = new Date(this.toDate)
87
+ private totalDays: number = d3.timeDay.count(this.fromTime, d3.timeDay.offset(this.toTime, 1))
88
+ private _timeScale!: d3.ScaleTime<number, number, never>
89
+
90
+ @query('svg[header]') svgHeader!: SVGElement
91
+ @query('svg[body]') svgBody!: SVGElement
92
+ @query('[chart-container]') container!: HTMLDivElement
93
+
94
+ protected firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
95
+ this.initHeaderGestureHandlers()
96
+ }
97
+
98
+ updated(changedProperties: PropertyValues<this>) {
99
+ var redrawRequired = false
100
+
101
+ if (changedProperties.has('fromDate') || changedProperties.has('toDate')) {
102
+ this.fromTime = new Date(this.fromDate)
103
+ this.toTime = new Date(this.toDate)
104
+ this.totalDays = d3.timeDay.count(this.fromTime, d3.timeDay.offset(this.toTime, 1))
105
+
106
+ redrawRequired = true
107
+ }
108
+
109
+ if (changedProperties.has('axisScale')) {
110
+ redrawRequired = true
111
+ }
112
+
113
+ if (changedProperties.has('tasks')) {
114
+ this.svgBody.style.height = this.tasks.length * 30 + 'px'
115
+ redrawRequired = true
116
+ }
117
+
118
+ redrawRequired && this.redraw()
119
+ }
120
+
121
+ render() {
122
+ return html`
123
+ <div chart-container>
124
+ <svg
125
+ header
126
+ @date-range-selected=${(e: CustomEvent) => {
127
+ console.log(e.detail)
128
+ }}
129
+ @wheel=${this.handleWheelForHeader.bind(this)}
130
+ ></svg>
131
+ <svg body></svg>
132
+ </div>
133
+ `
134
+ }
135
+
136
+ setVerticalOffset(offset: number) {
137
+ console.log(offset)
138
+ this.container.scrollTop = offset
139
+ }
140
+
141
+ getScale() {
142
+ if (this.level == 'quarter-week') {
143
+ return 7 / 2
144
+ } else if (this.level == 'year-month') {
145
+ return 30 / 2
146
+ } else if (this.level == 'year-quarter') {
147
+ return 90 / 2
148
+ }
149
+ return 1
150
+ }
151
+
152
+ getChartWidth() {
153
+ return (this.totalDays * this.baseWidth) / this.getScale() // 총 차트 폭 계산
154
+ }
155
+
156
+ get timeScale() {
157
+ if (!this._timeScale) {
158
+ // 시간축 스케일 생성
159
+ this._timeScale = d3
160
+ .scaleTime()
161
+ .domain([d3.timeDay.floor(this.fromTime), d3.timeDay.offset(this.toTime, 1)])
162
+ .range([0, this.getChartWidth()])
163
+ }
164
+
165
+ return this._timeScale
166
+ }
167
+
168
+ updateTimeScale() {
169
+ this.timeScale.range([0, this.getChartWidth()])
170
+ }
171
+
172
+ redraw() {
173
+ this.drawAxes()
174
+ this.drawBody()
175
+ }
176
+
177
+ drawBody() {
178
+ const body = d3.select(this.svgBody)
179
+
180
+ body.selectAll('*').remove()
181
+ body.attr('width', this.getChartWidth())
182
+
183
+ if (this.extendGridLines) {
184
+ const addGridLines = (ticks: Date[]) => {
185
+ ticks.forEach(tick => {
186
+ const startX = this.timeScale(tick)
187
+ body
188
+ .append('line')
189
+ .attr('x1', startX)
190
+ .attr('x2', startX)
191
+ .attr('y1', 0)
192
+ .attr('y2', Number(this.svgBody.getBoundingClientRect().height) || 0)
193
+ .attr('stroke', 'var(--ox-gantt-color-gridlines, var(--md-sys-color-outline))')
194
+ .attr('stroke-width', 1)
195
+ })
196
+ }
197
+
198
+ if (this.level === 'week-day') {
199
+ const dayTicks = d3.timeDay.every(1)!.range(d3.timeDay.floor(this.fromTime), d3.timeDay.offset(this.toTime, 1))
200
+ addGridLines(dayTicks)
201
+ } else if (this.level === 'quarter-week') {
202
+ const weekTicks = d3.timeWeek
203
+ .every(1)!
204
+ .range(d3.timeWeek.floor(this.fromTime), d3.timeWeek.offset(this.toTime, 1))
205
+ addGridLines(weekTicks)
206
+ } else if (this.level === 'year-month') {
207
+ const monthTicks = d3.timeMonth
208
+ .every(1)!
209
+ .range(d3.timeMonth.floor(this.fromTime), d3.timeMonth.offset(this.toTime, 1))
210
+ addGridLines(monthTicks)
211
+ } else if (this.level === 'year-quarter') {
212
+ const quarterTicks = d3.timeMonth
213
+ .every(3)!
214
+ .range(d3.timeMonth.floor(this.fromTime), d3.timeMonth.offset(this.toTime, 3))
215
+ addGridLines(quarterTicks)
216
+ }
217
+ }
218
+
219
+ const taskPositions: { [key: string]: { x: number; y: number; width: number } } = {}
220
+
221
+ // Add task bars
222
+ this.tasks.forEach((task, index) => {
223
+ const startX = this.timeScale(d3.timeDay.floor(d3.min([task.startTime, task.endTime])!))
224
+ const endX = this.timeScale(d3.timeDay.offset(d3.timeDay.ceil(d3.max([task.startTime, task.endTime])!), -1))
225
+
226
+ const taskWidth = endX - startX + this.baseWidth / this.getScale()
227
+ const taskY = index * 30 + 7.5
228
+
229
+ // Main task bar
230
+ const taskBar = body
231
+ .append('rect')
232
+ .attr('x', startX)
233
+ .attr('y', taskY)
234
+ .attr('width', taskWidth)
235
+ .attr('height', 15)
236
+ .attr('fill', this.getTaskColor(task.tags || []))
237
+ .attr('stroke', 'black')
238
+ .attr('stroke-width', 1)
239
+
240
+ // Add click event listener to task bar
241
+ taskBar.on('click', () => {
242
+ const customEvent = new CustomEvent('task-clicked', {
243
+ detail: { task },
244
+ bubbles: true,
245
+ composed: true
246
+ })
247
+ this.dispatchEvent(customEvent)
248
+ })
249
+
250
+ // Progress bar
251
+ if (task.progress !== undefined) {
252
+ const progressWidth = taskWidth * (task.progress / 100)
253
+ body
254
+ .append('rect')
255
+ .attr('x', startX)
256
+ .attr('y', taskY + 7.5 - 3.75) // Adjust to center within the task bar
257
+ .attr('width', progressWidth)
258
+ .attr('height', 7.5) // Set height to 50% of the task bar height
259
+ .attr('fill', 'lightgreen')
260
+ }
261
+
262
+ // Save task position for linking lines
263
+ taskPositions[task.id] = { x: endX + this.baseWidth / this.getScale(), y: taskY + 7.5, width: taskWidth }
264
+ })
265
+
266
+ // Add linking lines
267
+ this.tasks.forEach(task => {
268
+ if (task.after && taskPositions[task.after]) {
269
+ const prevTask = taskPositions[task.after]
270
+ const currTask = taskPositions[task.id]
271
+
272
+ // Draw the linking line
273
+ body
274
+ .append('path')
275
+ .attr(
276
+ 'd',
277
+ `
278
+ M${prevTask.x},${prevTask.y}
279
+ L${prevTask.x + 10},${prevTask.y}
280
+ ${
281
+ prevTask.x + 10 < currTask.x - 10 - currTask.width
282
+ ? `
283
+ L${(prevTask.x + currTask.x - currTask.width) / 2},${prevTask.y}
284
+ L${(prevTask.x + currTask.x - currTask.width) / 2},${currTask.y}
285
+ `
286
+ : `
287
+ L${prevTask.x + 10},${(prevTask.y + currTask.y) / 2}
288
+ L${currTask.x - 10 - currTask.width},${(prevTask.y + currTask.y) / 2}
289
+ `
290
+ }
291
+ L${currTask.x - 10 - currTask.width},${currTask.y}
292
+ L${currTask.x - currTask.width},${currTask.y}
293
+ `
294
+ )
295
+ .attr('stroke', 'black')
296
+ .attr('fill', 'none')
297
+ .attr('stroke-width', 1)
298
+ }
299
+ })
300
+ }
301
+
302
+ // Helper function to determine task color based on tags
303
+ getTaskColor(tags: TaskTag[]): string {
304
+ if (tags.includes('critical')) {
305
+ return 'red'
306
+ } else if (tags.includes('milestone')) {
307
+ return 'blue'
308
+ } else if (tags.includes('done')) {
309
+ return 'green'
310
+ } else {
311
+ return 'grey'
312
+ }
313
+ }
314
+
315
+ drawAxes() {
316
+ const header = d3.select(this.svgHeader)
317
+
318
+ header.selectAll('*').remove()
319
+ header.attr('width', this.getChartWidth())
320
+
321
+ // 차트 컨테이너의 폭 설정 및 스크롤 가능하도록 설정
322
+ header.attr('width', this.getChartWidth())
323
+
324
+ const addGridLines = (ticks: Date[], yStart: number, yEnd: number, extendable?: boolean) => {
325
+ ticks.forEach(tick => {
326
+ const startX = this.timeScale(tick)
327
+ header
328
+ .append('line')
329
+ .attr('x1', startX)
330
+ .attr('x2', startX)
331
+ .attr('y1', yStart)
332
+ .attr('y2', yEnd)
333
+ .attr('stroke', 'var(--ox-gantt-color-gridlines, var(--md-sys-color-outline))')
334
+ .attr('stroke-width', 1)
335
+ })
336
+ }
337
+
338
+ const centerLabel = (
339
+ selection: d3.Selection<SVGGElement, unknown, null, any>,
340
+ tickInterval: d3.CountableTimeInterval
341
+ ) => {
342
+ selection.selectAll('text').attr('transform', (d: any) => {
343
+ const tickIntervalWidth = this.timeScale(tickInterval.offset(d, 1)) - this.timeScale(d)
344
+ return `translate(${tickIntervalWidth / 2}, -2)`
345
+ })
346
+ }
347
+
348
+ if (this.level === 'week-day') {
349
+ // Level 1: week-day
350
+ // Weekly axis
351
+ const weekAxis = d3
352
+ .axisTop(this.timeScale)
353
+ .ticks(d3.timeWeek.every(1))
354
+ .tickFormat(d3.timeFormat('%B %d') as any)
355
+ .tickSize(0)
356
+ const weekTicks = d3.timeWeek
357
+ .every(1)!
358
+ .range(d3.timeWeek.floor(this.fromTime), d3.timeWeek.offset(this.toTime, 1))
359
+ addGridLines(weekTicks, 0, 20)
360
+
361
+ const weekAxisG = header
362
+ .append('g')
363
+ .classed('week-axis no-select md-typescale-label-small', true)
364
+ .attr('transform', `translate(0, 20)`)
365
+ .call(weekAxis)
366
+
367
+ centerLabel(weekAxisG, d3.timeWeek)
368
+
369
+ // Daily axis
370
+ const dayAxis = d3
371
+ .axisTop(this.timeScale)
372
+ .ticks(d3.timeDay.every(1))
373
+ .tickFormat(d3.timeFormat('%d') as any)
374
+ .tickSize(0)
375
+
376
+ const dayTicks = d3.timeDay.every(1)!.range(d3.timeDay.floor(this.fromTime), d3.timeDay.offset(this.toTime, 1))
377
+ addGridLines(dayTicks, 20, 40)
378
+
379
+ const dayAxisG = header
380
+ .append('g')
381
+ .classed('day-axis no-select md-typescale-label-small', true)
382
+ .attr('transform', `translate(0, 40)`)
383
+ .call(dayAxis)
384
+
385
+ centerLabel(dayAxisG, d3.timeDay)
386
+ } else if (this.level === 'quarter-week') {
387
+ // Level 2: quarter-week
388
+ // Quarterly axis
389
+ const quarterAxis = d3
390
+ .axisTop(this.timeScale)
391
+ .ticks(d3.timeMonth.every(3))
392
+ .tickFormat(d3.timeFormat('%Y %b') as any)
393
+ .tickSize(0)
394
+
395
+ const quarterTicks = d3.timeMonth
396
+ .every(3)!
397
+ .range(d3.timeMonth.floor(this.fromTime), d3.timeMonth.offset(this.toTime, 3))
398
+ addGridLines(quarterTicks, 0, 20)
399
+
400
+ const quarterAxisG = header
401
+ .append('g')
402
+ .classed('quarter-axis no-select md-typescale-label-small', true)
403
+ .attr('transform', `translate(0, 20)`)
404
+ .call(quarterAxis)
405
+
406
+ centerLabel(quarterAxisG, d3.timeMonth.every(3) as any)
407
+
408
+ // Weekly axis
409
+ const weekAxis = d3
410
+ .axisTop(this.timeScale)
411
+ .ticks(d3.timeWeek.every(1))
412
+ .tickFormat(d3.timeFormat('%b %d') as any)
413
+ .tickSize(0)
414
+ const weekTicks = d3.timeWeek
415
+ .every(1)!
416
+ .range(d3.timeWeek.floor(this.fromTime), d3.timeWeek.offset(this.toTime, 1))
417
+ addGridLines(weekTicks, 20, 40)
418
+
419
+ const weekAxisG = header
420
+ .append('g')
421
+ .classed('week-axis no-select md-typescale-label-small', true)
422
+ .attr('transform', `translate(0, 40)`)
423
+ .call(weekAxis)
424
+
425
+ centerLabel(weekAxisG, d3.timeWeek)
426
+ } else if (this.level === 'year-month') {
427
+ // Level 3: year-month
428
+ // Yearly axis
429
+ const yearAxis = d3
430
+ .axisTop(this.timeScale)
431
+ .ticks(d3.timeYear.every(1))
432
+ .tickFormat(d3.timeFormat('%Y') as any)
433
+ .tickSize(0)
434
+
435
+ const yearTicks = d3.timeYear
436
+ .every(1)!
437
+ .range(d3.timeYear.floor(this.fromTime), d3.timeYear.offset(this.toTime, 1))
438
+ addGridLines(yearTicks, 0, 20)
439
+
440
+ const yearAxisG = header
441
+ .append('g')
442
+ .classed('year-axis no-select md-typescale-label-small', true)
443
+ .attr('transform', `translate(0, 20)`)
444
+ .call(yearAxis)
445
+
446
+ centerLabel(yearAxisG, d3.timeYear.every(1)! as any)
447
+
448
+ // Monthly axis
449
+ const monthAxis = d3
450
+ .axisTop(this.timeScale)
451
+ .ticks(d3.timeMonth.every(1))
452
+ .tickFormat(d3.timeFormat('%b') as any)
453
+ .tickSize(0)
454
+
455
+ const monthTicks = d3.timeMonth
456
+ .every(1)!
457
+ .range(d3.timeMonth.floor(this.fromTime), d3.timeMonth.offset(this.toTime, 1))
458
+ addGridLines(monthTicks, 20, 40)
459
+
460
+ const monthAxisG = header
461
+ .append('g')
462
+ .classed('month-axis no-select md-typescale-label-small', true)
463
+ .attr('transform', `translate(0, 40)`)
464
+ .call(monthAxis)
465
+
466
+ centerLabel(monthAxisG, d3.timeMonth.every(1) as any)
467
+ } else if (this.level === 'year-quarter') {
468
+ // Level 4: year-quarter
469
+ // Yearly axis
470
+ const yearAxis = d3
471
+ .axisTop(this.timeScale)
472
+ .ticks(d3.timeYear.every(1))
473
+ .tickFormat(d3.timeFormat('%Y') as any)
474
+ .tickSize(0)
475
+
476
+ const yearTicks = d3.timeYear
477
+ .every(1)!
478
+ .range(d3.timeYear.floor(this.fromTime), d3.timeYear.offset(this.toTime, 1))
479
+ addGridLines(yearTicks, 0, 20)
480
+
481
+ const yearAxisG = header
482
+ .append('g')
483
+ .classed('year-axis no-select md-typescale-label-small', true)
484
+ .attr('transform', `translate(0, 20)`)
485
+ .call(yearAxis)
486
+
487
+ centerLabel(yearAxisG, d3.timeYear.every(1)! as any)
488
+
489
+ // Quarterly axis
490
+ const quarterAxis = d3
491
+ .axisTop(this.timeScale)
492
+ .ticks(d3.timeMonth.every(3))
493
+ .tickFormat(d3.timeFormat('%Y %b') as any)
494
+ .tickSize(0)
495
+
496
+ const quarterTicks = d3.timeMonth
497
+ .every(3)!
498
+ .range(d3.timeMonth.floor(this.fromTime), d3.timeMonth.offset(this.toTime, 3))
499
+ addGridLines(quarterTicks, 20, 40)
500
+
501
+ const quarterAxisG = header
502
+ .append('g')
503
+ .classed('quarter-axis no-select md-typescale-label-small', true)
504
+ .attr('transform', `translate(0, 40)`)
505
+ .call(quarterAxis)
506
+
507
+ centerLabel(quarterAxisG, d3.timeMonth.every(3) as any)
508
+ }
509
+ }
510
+
511
+ initHeaderGestureHandlers() {
512
+ const header = d3.select(this.svgHeader)
513
+
514
+ let isDragging = false
515
+ let startX: number | null = null
516
+ let lastTouchDistance: number | null = null
517
+
518
+ const handleTouchStart = (event: TouchEvent) => {
519
+ event.preventDefault() // 기본 터치 이벤트 막기
520
+
521
+ if (event.touches.length === 2) {
522
+ lastTouchDistance = getTouchDistance(event.touches)
523
+ } else {
524
+ isDragging = true
525
+ const touch = event.touches[0]
526
+ const containerOffsetX = header.node()!.getBoundingClientRect().left
527
+ startX = touch.clientX - containerOffsetX
528
+ }
529
+ }
530
+
531
+ const handleTouchMove = (event: TouchEvent) => {
532
+ event.preventDefault() // 기본 터치 이벤트 막기
533
+
534
+ if (event.touches.length === 2 && lastTouchDistance !== null) {
535
+ const currentDistance = getTouchDistance(event.touches)
536
+ const zoomFactor = currentDistance / lastTouchDistance
537
+ this.baseWidth *= zoomFactor
538
+
539
+ if (this.baseWidth > 60) {
540
+ if (this.level == 'year-quarter') {
541
+ this.baseWidth = 60
542
+ } else {
543
+ this.level = levelUp(this.level)
544
+ this.baseWidth = 30
545
+ }
546
+ } else if (this.baseWidth < 30) {
547
+ if (this.level == 'week-day') {
548
+ this.baseWidth = 30
549
+ } else {
550
+ this.level = levelDown(this.level)
551
+ this.baseWidth = 60
552
+ }
553
+ }
554
+
555
+ lastTouchDistance = currentDistance
556
+ this.updateTimeScale()
557
+ this.redraw()
558
+ } else if (isDragging && startX !== null) {
559
+ const touch = event.touches[0]
560
+ const containerOffsetX = header.node()!.getBoundingClientRect().left
561
+ const currentX = touch.clientX - containerOffsetX
562
+ this.highlightSelection(startX, currentX)
563
+ }
564
+ }
565
+
566
+ const handleTouchEnd = (event: TouchEvent) => {
567
+ event.preventDefault() // 기본 터치 이벤트 막기
568
+
569
+ if (event.touches.length < 2) {
570
+ lastTouchDistance = null
571
+ }
572
+
573
+ if (!isDragging || startX === null) {
574
+ // 클릭 이벤트 처리
575
+ const touch = event.changedTouches[0]
576
+ const containerOffsetX = header.node()!.getBoundingClientRect().left
577
+ const clickX = touch.clientX - containerOffsetX
578
+ this.finalizeSelection(clickX, clickX)
579
+ return
580
+ }
581
+
582
+ const touch = event.changedTouches[0]
583
+ const containerOffsetX = header.node()!.getBoundingClientRect().left
584
+ const endX = touch.clientX - containerOffsetX
585
+ this.finalizeSelection(startX, endX)
586
+
587
+ isDragging = false
588
+ startX = null
589
+ }
590
+
591
+ const getTouchDistance = (touches: TouchList): number => {
592
+ const dx = touches[0].clientX - touches[1].clientX
593
+ const dy = touches[0].clientY - touches[1].clientY
594
+ return Math.sqrt(dx * dx + dy * dy)
595
+ }
596
+
597
+ const handleMouseDown = (event: MouseEvent) => {
598
+ isDragging = true
599
+ const containerOffsetX = header.node()!.getBoundingClientRect().left
600
+ startX = event.clientX - containerOffsetX
601
+ }
602
+
603
+ const handleMouseMove = (event: MouseEvent) => {
604
+ if (!isDragging || startX === null) return
605
+
606
+ const containerOffsetX = header.node()!.getBoundingClientRect().left
607
+ const currentX = event.clientX - containerOffsetX
608
+ this.highlightSelection(startX, currentX)
609
+ }
610
+
611
+ const handleMouseUp = (event: MouseEvent) => {
612
+ if (!isDragging || startX === null) {
613
+ // 클릭 이벤트 처리
614
+ const containerOffsetX = header.node()!.getBoundingClientRect().left
615
+ const clickX = event.clientX - containerOffsetX
616
+ this.finalizeSelection(clickX, clickX)
617
+ return
618
+ }
619
+
620
+ const containerOffsetX = header.node()!.getBoundingClientRect().left
621
+ const endX = event.clientX - containerOffsetX
622
+ this.finalizeSelection(startX, endX)
623
+
624
+ isDragging = false
625
+ startX = null
626
+ }
627
+
628
+ header.on('mousedown', handleMouseDown)
629
+ header.on('mousemove', handleMouseMove)
630
+ header.on('mouseup', handleMouseUp)
631
+ header.on('touchstart', handleTouchStart)
632
+ header.on('touchmove', handleTouchMove)
633
+ header.on('touchend', handleTouchEnd)
634
+ }
635
+
636
+ highlightSelection(startX: number, endX: number) {
637
+ const header = d3.select(this.svgHeader)
638
+ const body = d3.select(this.svgBody)
639
+
640
+ let startDate = this.timeScale.invert(startX)
641
+ let endDate = this.timeScale.invert(endX)
642
+
643
+ let start, end
644
+
645
+ if (this.level === 'week-day') {
646
+ start = d3.timeDay.floor(d3.min([startDate, endDate])!)
647
+ end = d3.timeDay.offset(d3.timeDay.ceil(d3.max([startDate, endDate])!), -1)
648
+ } else if (this.level === 'quarter-week') {
649
+ start = d3.timeWeek.floor(d3.min([startDate, endDate])!)
650
+ end = d3.timeDay.offset(d3.timeWeek.ceil(d3.max([startDate, endDate])!), -1)
651
+ } else if (this.level === 'year-month') {
652
+ start = d3.timeMonth.floor(d3.min([startDate, endDate])!)
653
+ end = d3.timeDay.offset(d3.timeMonth.ceil(d3.max([startDate, endDate])!), -1)
654
+ } else if (this.level === 'year-quarter') {
655
+ start = d3.timeMonth.every(3)!.floor(d3.min([startDate, endDate])!)
656
+ end = d3.timeDay.offset(d3.timeMonth.every(3)!.ceil(d3.max([startDate, endDate])!), -1)
657
+ }
658
+
659
+ header.selectAll('.selection-rect').remove()
660
+ body.selectAll('.selection-rect').remove()
661
+
662
+ header
663
+ .append('rect')
664
+ .attr('class', 'selection-rect')
665
+ .attr('x', this.timeScale(start!))
666
+ .attr('y', 20)
667
+ .attr('width', this.timeScale(end!) - this.timeScale(start!) + this.baseWidth / this.getScale())
668
+ .attr('height', 20)
669
+ .attr('fill', 'var(--ox-gantt-selection-color, var(--md-sys-color-secondary))')
670
+ .attr('opacity', '.2')
671
+
672
+ body
673
+ .append('rect')
674
+ .attr('class', 'selection-rect')
675
+ .attr('x', this.timeScale(start!))
676
+ .attr('y', 0)
677
+ .attr('width', this.timeScale(end!) - this.timeScale(start!) + this.baseWidth / this.getScale())
678
+ .attr('height', '100%')
679
+ .attr('fill', 'var(--ox-gantt-selection-color, var(--md-sys-color-secondary))')
680
+ .attr('opacity', '.2')
681
+ }
682
+
683
+ finalizeSelection(startX: number, endX: number) {
684
+ const header = d3.select(this.svgHeader)
685
+ const body = d3.select(this.svgBody)
686
+
687
+ let startDate = this.timeScale.invert(startX)
688
+ let endDate = this.timeScale.invert(endX)
689
+
690
+ let start, end
691
+
692
+ if (this.level === 'week-day') {
693
+ start = d3.timeDay.floor(d3.min([startDate, endDate])!)
694
+ end = d3.timeDay.offset(d3.timeDay.ceil(d3.max([startDate, endDate])!), -1)
695
+ } else if (this.level === 'quarter-week') {
696
+ start = d3.timeWeek.floor(d3.min([startDate, endDate])!)
697
+ end = d3.timeDay.offset(d3.timeWeek.ceil(d3.max([startDate, endDate])!), -1)
698
+ } else if (this.level === 'year-month') {
699
+ start = d3.timeMonth.floor(d3.min([startDate, endDate])!)
700
+ end = d3.timeDay.offset(d3.timeMonth.ceil(d3.max([startDate, endDate])!), -1)
701
+ } else if (this.level === 'year-quarter') {
702
+ start = d3.timeMonth.every(3)!.floor(d3.min([startDate, endDate])!)
703
+ end = d3.timeDay.offset(d3.timeMonth.every(3)!.ceil(d3.max([startDate, endDate])!), -1)
704
+ }
705
+
706
+ header.selectAll('.selection-rect').remove()
707
+ body.selectAll('.selection-rect').remove()
708
+
709
+ header
710
+ .append('rect')
711
+ .attr('class', 'selection-rect')
712
+ .attr('x', this.timeScale(start!))
713
+ .attr('y', 20)
714
+ .attr('width', this.timeScale(end!) - this.timeScale(start!) + this.baseWidth / this.getScale())
715
+ .attr('height', 20)
716
+ .attr('fill', 'var(--ox-gantt-selection-color, var(--md-sys-color-secondary))')
717
+ .attr('opacity', '.2')
718
+
719
+ body
720
+ .append('rect')
721
+ .attr('class', 'selection-rect')
722
+ .attr('x', this.timeScale(start!))
723
+ .attr('y', 0)
724
+ .attr('width', this.timeScale(end!) - this.timeScale(start!) + this.baseWidth / this.getScale())
725
+ .attr('height', '100%')
726
+ .attr('fill', 'var(--ox-gantt-selection-color, var(--md-sys-color-secondary))')
727
+ .attr('opacity', '.2')
728
+
729
+ const formatDate = d3.timeFormat('%Y-%m-%d')
730
+ const customEvent = new CustomEvent('date-range-selected', {
731
+ detail: { start: formatDate(start!), end: formatDate(end!) }
732
+ })
733
+
734
+ header.node()?.dispatchEvent(customEvent)
735
+ }
736
+
737
+ handleWheelForHeader(event: WheelEvent) {
738
+ event.preventDefault()
739
+ event.stopPropagation()
740
+
741
+ if (event.altKey || event.ctrlKey || event.shiftKey || event.metaKey) {
742
+ // 좌우 스크롤
743
+ this.container.scrollBy({
744
+ left: event.shiftKey ? event.deltaX : event.deltaY,
745
+ behavior: 'smooth'
746
+ })
747
+ } else {
748
+ // Zoom in/out
749
+ const zoomFactor = event.deltaY < 0 ? 1.1 : 0.9
750
+ this.baseWidth *= zoomFactor
751
+
752
+ if (this.baseWidth > 40) {
753
+ if (this.level == 'week-day') {
754
+ this.baseWidth = 40
755
+ } else {
756
+ this.level = levelDown(this.level)
757
+ this.baseWidth = 30
758
+ }
759
+ } else if (this.baseWidth < 30) {
760
+ if (this.level == 'year-quarter') {
761
+ this.baseWidth = 30
762
+ }
763
+ }
764
+ if (this.baseWidth < 20) {
765
+ this.level = levelUp(this.level)
766
+ this.baseWidth = 30
767
+ }
768
+
769
+ this.updateTimeScale()
770
+ this.redraw()
771
+ }
772
+ }
773
+ }