@operato/gantt 2.0.0-beta.13 → 2.0.0-beta.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/src/gantt-chart-controller.d.ts +32 -0
  3. package/dist/src/gantt-chart-controller.js +393 -0
  4. package/dist/src/gantt-chart-controller.js.map +1 -0
  5. package/dist/src/locales.d.ts +52 -0
  6. package/dist/src/locales.js +79 -0
  7. package/dist/src/locales.js.map +1 -0
  8. package/dist/src/ox-gantt-chart.d.ts +10 -17
  9. package/dist/src/ox-gantt-chart.js +135 -435
  10. package/dist/src/ox-gantt-chart.js.map +1 -1
  11. package/dist/src/ox-gantt-resize-splitter.d.ts +2 -3
  12. package/dist/src/ox-gantt-resize-splitter.js +15 -22
  13. package/dist/src/ox-gantt-resize-splitter.js.map +1 -1
  14. package/dist/src/ox-gantt.d.ts +14 -1
  15. package/dist/src/ox-gantt.js +149 -28
  16. package/dist/src/ox-gantt.js.map +1 -1
  17. package/dist/src/ox-task-tooltip.d.ts +12 -0
  18. package/dist/src/ox-task-tooltip.js +132 -0
  19. package/dist/src/ox-task-tooltip.js.map +1 -0
  20. package/dist/src/types.d.ts +10 -3
  21. package/dist/src/types.js.map +1 -1
  22. package/dist/stories/ox-gantt.stories.d.ts +12 -0
  23. package/dist/stories/ox-gantt.stories.js +100 -88
  24. package/dist/stories/ox-gantt.stories.js.map +1 -1
  25. package/dist/tsconfig.tsbuildinfo +1 -1
  26. package/package.json +3 -2
  27. package/src/gantt-chart-controller.ts +470 -0
  28. package/src/locales.ts +78 -0
  29. package/src/ox-gantt-chart.ts +143 -481
  30. package/src/ox-gantt-resize-splitter.ts +18 -25
  31. package/src/ox-gantt.ts +153 -30
  32. package/src/ox-task-tooltip.ts +120 -0
  33. package/src/types.ts +11 -3
  34. package/stories/ox-gantt.stories.ts +105 -88
  35. package/translations/en.json +11 -1
  36. package/translations/ja.json +11 -1
  37. package/translations/ko.json +11 -1
  38. package/translations/ms.json +11 -1
  39. package/translations/zh.json +11 -1
  40. package/dist/src/locale.d.ts +0 -1
  41. package/dist/src/locale.js +0 -49
  42. package/dist/src/locale.js.map +0 -1
  43. package/src/locale.ts +0 -51
@@ -1,5 +1,4 @@
1
1
  import { LitElement, css, html } from 'lit'
2
-
3
2
  import { customElement } from 'lit/decorators.js'
4
3
 
5
4
  @customElement('ox-gantt-resize-splitter')
@@ -39,20 +38,7 @@ export class OxGanttResizeSplitter extends LitElement {
39
38
  }
40
39
 
41
40
  render() {
42
- return html` <div @mousedown=${(e: MouseEvent) => this.onMouseDown(e)}></div> `
43
- }
44
-
45
- // TODO onDrag 이벤트가 계속 발생하므로 처리하는 성능 저하됨. 그래서 throttling 하도록 함
46
- _throttled(delay: number, fn: (...args: any) => void) {
47
- let lastCall = 0
48
- return function (...args: any) {
49
- const now = new Date().getTime()
50
- if (now - lastCall < delay) {
51
- return
52
- }
53
- lastCall = now
54
- return fn(...args)
55
- }
41
+ return html`<div @mousedown=${(e: MouseEvent) => this.onMouseDown(e)}></div>`
56
42
  }
57
43
 
58
44
  private onMouseDown(e: MouseEvent) {
@@ -68,20 +54,33 @@ export class OxGanttResizeSplitter extends LitElement {
68
54
  })
69
55
  )
70
56
 
57
+ this.parentElement!.addEventListener('mousemove', this.onMouseMoveHandler)
58
+ this.parentElement!.addEventListener('mouseup', this.onMouseUpHandler)
71
59
  window.addEventListener('mousemove', this.onMouseMoveHandler)
72
60
  window.addEventListener('mouseup', this.onMouseUpHandler)
73
61
 
74
62
  e.stopPropagation()
75
63
  }
76
64
 
77
- onMouseMoveHandler = this._throttled(100, this.onMouseMove.bind(this)) // this.onMouseMove.bind(this)
78
- onMouseUpHandler = this.onMouseUp.bind(this)
65
+ private onMouseMoveHandler = (e: MouseEvent) => this.onMouseMove(e)
66
+ private onMouseUpHandler = (e: MouseEvent) => this.onMouseUp(e)
79
67
 
80
68
  private onMouseMove(e: MouseEvent) {
81
69
  if (!this.dragstart) {
82
70
  return
83
71
  }
84
72
 
73
+ const parentRect = this.parentElement!.getBoundingClientRect()
74
+ if (
75
+ e.clientX < parentRect.left ||
76
+ e.clientX > parentRect.right ||
77
+ e.clientY < parentRect.top ||
78
+ e.clientY > parentRect.bottom
79
+ ) {
80
+ this.onMouseUp(e)
81
+ return
82
+ }
83
+
85
84
  e.preventDefault()
86
85
 
87
86
  this.dispatchEvent(
@@ -94,9 +93,6 @@ export class OxGanttResizeSplitter extends LitElement {
94
93
  }
95
94
  })
96
95
  )
97
- requestAnimationFrame(() => {
98
- dispatchEvent(new Event('resize'))
99
- })
100
96
 
101
97
  if (this.hasAttribute('vertical')) {
102
98
  document.body.style.cursor = 'col-resize'
@@ -110,6 +106,8 @@ export class OxGanttResizeSplitter extends LitElement {
110
106
  }
111
107
 
112
108
  private onMouseUp(e: MouseEvent) {
109
+ this.parentElement!.removeEventListener('mousemove', this.onMouseMoveHandler)
110
+ this.parentElement!.removeEventListener('mouseup', this.onMouseUpHandler)
113
111
  window.removeEventListener('mousemove', this.onMouseMoveHandler)
114
112
  window.removeEventListener('mouseup', this.onMouseUpHandler)
115
113
 
@@ -124,11 +122,6 @@ export class OxGanttResizeSplitter extends LitElement {
124
122
  })
125
123
  )
126
124
 
127
- requestAnimationFrame(() => {
128
- dispatchEvent(new Event('resize'))
129
- delete this.dragstart
130
- })
131
-
132
125
  document.body.style.cursor = ''
133
126
  document.body.style.userSelect = ''
134
127
 
package/src/ox-gantt.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { LitElement, html, css, PropertyValues, PropertyValueMap } from 'lit'
2
- import { customElement, property, query } from 'lit/decorators.js'
2
+ import i18next from 'i18next'
3
+ import { customElement, property, query, state } from 'lit/decorators.js'
3
4
  import { styles as MDTypeScaleStyles } from '@material/web/typography/md-typescale-styles'
4
5
  import { ScrollbarStyles } from '@operato/styles'
5
6
 
6
7
  import './ox-gantt-chart.js'
7
8
  import './ox-gantt-resize-splitter.js'
8
9
  import { OxGanttChart } from './ox-gantt-chart.js'
9
- import { Task } from './types.js'
10
+ import { ColumnConfig, Task } from './types.js'
10
11
 
11
12
  @customElement('ox-gantt')
12
13
  export class OxGantt extends LitElement {
@@ -18,6 +19,7 @@ export class OxGantt extends LitElement {
18
19
  display: block;
19
20
  font-family: Arial, sans-serif;
20
21
  text-wrap: nowrap;
22
+ position: relative;
21
23
  }
22
24
 
23
25
  .container {
@@ -44,6 +46,7 @@ export class OxGantt extends LitElement {
44
46
 
45
47
  ox-gantt-resize-splitter {
46
48
  width: 4px;
49
+ cursor: ew-resize;
47
50
  }
48
51
 
49
52
  table {
@@ -59,6 +62,7 @@ export class OxGantt extends LitElement {
59
62
  top: -1px;
60
63
  z-index: 1;
61
64
  background-color: var(--ox-gantt-header-background-color, var(--md-sys-color-secondary-container));
65
+ text-transform: capitalize;
62
66
  }
63
67
 
64
68
  th,
@@ -77,6 +81,10 @@ export class OxGantt extends LitElement {
77
81
  height: 30px;
78
82
  box-sizing: border-box;
79
83
  }
84
+
85
+ tr.selected {
86
+ background-color: var(--ox-gantt-selected-background-color, var(--md-sys-color-surface-variant));
87
+ }
80
88
  `
81
89
  ]
82
90
 
@@ -85,6 +93,51 @@ export class OxGantt extends LitElement {
85
93
  fromDate: string = new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0] /* beginning of this year */
86
94
  @property({ type: String, attribute: 'to-date' })
87
95
  toDate: string = new Date(new Date().getFullYear(), 11, 31).toISOString().split('T')[0] /* end of this year */
96
+ @property({ type: Boolean, attribute: 'extend-grid-lines' }) extendGridLines: boolean = false
97
+
98
+ @state() selectedTaskId?: string
99
+ @state()
100
+ private columnConfig: ColumnConfig[] = [
101
+ { id: 'id', label: i18next.t('label.gantt-task-id') || 'ID', visible: true, width: '50px', order: 1 },
102
+ {
103
+ id: 'section',
104
+ label: i18next.t('label.gantt-task-section') || 'section',
105
+ visible: true,
106
+ width: '100px',
107
+ order: 2
108
+ },
109
+ { id: 'title', label: i18next.t('label.gantt-task-title') || 'title', visible: true, width: '150px', order: 3 },
110
+ { id: 'type', label: i18next.t('label.gantt-task-type') || 'type', visible: true, width: '100px', order: 4 },
111
+ {
112
+ id: 'startDate',
113
+ label: i18next.t('label.gantt-task-start-date') || 'start date',
114
+ visible: true,
115
+ width: '100px',
116
+ order: 5
117
+ },
118
+ {
119
+ id: 'endDate',
120
+ label: i18next.t('label.gantt-task-end-date') || 'end date',
121
+ visible: true,
122
+ width: '100px',
123
+ order: 6
124
+ },
125
+ { id: 'tags', label: i18next.t('label.gantt-task-tags') || 'tags', visible: true, width: '100px', order: 7 },
126
+ {
127
+ id: 'progress',
128
+ label: i18next.t('label.gantt-task-progress') || 'progress',
129
+ visible: true,
130
+ width: '100px',
131
+ order: 8
132
+ },
133
+ {
134
+ id: 'dependsOn',
135
+ label: i18next.t('label.gantt-task-previous') || 'previous',
136
+ visible: true,
137
+ width: '100px',
138
+ order: 9
139
+ }
140
+ ]
88
141
 
89
142
  @query('.container') container!: HTMLDivElement
90
143
  @query('.task-container') taskContainer!: HTMLDivElement
@@ -93,6 +146,7 @@ export class OxGantt extends LitElement {
93
146
  @query('ox-gantt-chart') ganttChart!: OxGanttChart
94
147
 
95
148
  private _startWidth: number = 0
149
+ private _isDragging: boolean = false
96
150
 
97
151
  render() {
98
152
  return html`
@@ -102,6 +156,7 @@ export class OxGantt extends LitElement {
102
156
  vertical
103
157
  @splitter-dragstart=${(e: CustomEvent) => this.resizeStart(e)}
104
158
  @splitter-drag=${(e: CustomEvent) => this.resizeDrag(e)}
159
+ @splitter-dragend=${(e: CustomEvent) => this.resizeEnd(e)}
105
160
  ></ox-gantt-resize-splitter>
106
161
  <ox-gantt-chart
107
162
  class="chart-container"
@@ -109,42 +164,42 @@ export class OxGantt extends LitElement {
109
164
  .fromDate=${this.fromDate}
110
165
  .toDate=${this.toDate}
111
166
  .tasks=${this.tasks}
167
+ .selectedTaskId=${this.selectedTaskId}
112
168
  axis-scale="week-day"
113
- extend-grid-lines
169
+ ?extend-grid-lines=${this.extendGridLines}
170
+ @task-clicked=${this.handleTaskClicked}
114
171
  ></ox-gantt-chart>
115
172
  </div>
116
173
  `
117
174
  }
118
175
 
119
176
  renderTaskTable() {
177
+ const mergedTasks = this.mergeTasksBySection(this.tasks)
178
+
179
+ // Sort columns by the configured order
180
+ const sortedColumns = [...this.columnConfig].sort((a, b) => a.order - b.order)
181
+
120
182
  return html`
121
183
  <table>
122
184
  <thead>
123
185
  <tr>
124
- <th>ID</th>
125
- <th>Section</th>
126
- <th>Title</th>
127
- <th>Type</th>
128
- <th>Start Time</th>
129
- <th>End Time</th>
130
- <th>Tags</th>
131
- <th>Progress</th>
132
- <th>Prev. Task</th>
186
+ ${sortedColumns.map(col => (col.visible ? html`<th style="width: ${col.width}">${col.label}</th>` : null))}
133
187
  </tr>
134
188
  </thead>
135
189
  <tbody>
136
- ${this.tasks.map(
137
- task => html`
138
- <tr>
139
- <td>${task.id}</td>
140
- <td>${task.section?.name || ''}</td>
141
- <td>${task.title}</td>
142
- <td>${task.type}</td>
143
- <td>${task.startTime.toISOString().split('T')[0]}</td>
144
- <td>${task.endTime.toISOString().split('T')[0]}</td>
145
- <td>${task.tags?.join(', ') || ''}</td>
146
- <td>${task.progress || ''}</td>
147
- <td>${task.after || ''}</td>
190
+ ${mergedTasks.map(
191
+ ({ task, showSection, rowSpan }) => html`
192
+ <tr class="${this.selectedTaskId === task.id ? 'selected' : ''}" @click=${() => this.selectTask(task.id)}>
193
+ ${sortedColumns.map(col => {
194
+ if (col.id === 'section') {
195
+ return col.visible && showSection
196
+ ? html`<td rowspan="${rowSpan}" style="width: ${col.width}">${task.section?.name || ''}</td>`
197
+ : null
198
+ }
199
+ return col.visible
200
+ ? html`<td style="width: ${col.width}">${this.getTaskFieldValue(task, col.id)}</td>`
201
+ : null
202
+ })}
148
203
  </tr>
149
204
  `
150
205
  )}
@@ -153,25 +208,93 @@ export class OxGantt extends LitElement {
153
208
  `
154
209
  }
155
210
 
211
+ getTaskFieldValue(task: Task, field: string) {
212
+ switch (field) {
213
+ case 'id':
214
+ return task.id
215
+ case 'section':
216
+ return task.section?.name || ''
217
+ case 'title':
218
+ return task.title
219
+ case 'type':
220
+ return task.type
221
+ case 'startDate':
222
+ return task.startDate.toISOString().split('T')[0]
223
+ case 'endDate':
224
+ return task.endDate.toISOString().split('T')[0]
225
+ case 'tags':
226
+ return task.tags?.join(', ') || ''
227
+ case 'progress':
228
+ return task.progress || ''
229
+ case 'dependsOn':
230
+ return task.dependsOn || ''
231
+ default:
232
+ return ''
233
+ }
234
+ }
235
+
236
+ mergeTasksBySection(tasks: Task[]): Array<{ task: Task; showSection: boolean; rowSpan: number }> {
237
+ const mergedTasks = [] as Array<{ task: Task; showSection: boolean; rowSpan: number }>
238
+ let currentSection = ''
239
+ let rowSpan = 0
240
+
241
+ tasks.forEach((task, index) => {
242
+ if (task.section?.name !== currentSection) {
243
+ currentSection = task.section?.name || ''
244
+ rowSpan = 1
245
+ mergedTasks.push({ task, showSection: true, rowSpan: 1 })
246
+ } else {
247
+ mergedTasks[mergedTasks.length - rowSpan].rowSpan = ++rowSpan
248
+ mergedTasks.push({ task, showSection: false, rowSpan: 0 })
249
+ }
250
+
251
+ if (index === tasks.length - 1 && rowSpan > 0) {
252
+ mergedTasks[mergedTasks.length - rowSpan].rowSpan = rowSpan
253
+ }
254
+ })
255
+
256
+ return mergedTasks
257
+ }
258
+
259
+ selectTask(taskId: string) {
260
+ this.selectedTaskId = taskId
261
+ }
262
+
263
+ handleTaskClicked(event: CustomEvent) {
264
+ this.selectedTaskId = event.detail.taskId
265
+ }
266
+
156
267
  protected firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
157
268
  this.taskContainer.style.width = '254px'
158
269
  }
159
270
 
160
271
  updated(changes: PropertyValues<this>) {}
161
272
 
162
- syncScroll() {
163
- this.ganttChart.setVerticalOffset(this.taskContainer.scrollTop)
273
+ syncScroll(e: Event) {
274
+ const target = e.target as HTMLElement
275
+ if (target === this.taskContainer) {
276
+ this.ganttChart.setVerticalOffset(this.taskContainer.scrollTop)
277
+ } else if (target === this.ganttChart) {
278
+ this.taskContainer.scrollTop = this.ganttChart.getVerticalOffset()
279
+ }
164
280
  }
165
281
 
166
282
  resizeStart(e: CustomEvent) {
167
283
  this._startWidth = ((e.target as HTMLElement)?.previousElementSibling as HTMLElement)?.offsetWidth
284
+ this._isDragging = true
168
285
  }
169
286
 
170
287
  resizeDrag(e: CustomEvent) {
171
- var delta = e.detail
172
- var x = (e.target as HTMLElement)?.previousElementSibling as HTMLElement
173
- if (x) {
174
- x.style.width = `${this._startWidth + delta.x}px`
288
+ if (!this._isDragging) return
289
+ const delta = e.detail
290
+ const taskContainer = this.taskContainer
291
+
292
+ if (taskContainer) {
293
+ taskContainer.style.width = `${this._startWidth + delta.x}px`
175
294
  }
176
295
  }
296
+
297
+ resizeEnd(e: CustomEvent) {
298
+ this._isDragging = false
299
+ }
177
300
  }
@@ -0,0 +1,120 @@
1
+ import { LitElement, html, css } from 'lit'
2
+ import { customElement, property } from 'lit/decorators.js'
3
+
4
+ @customElement('ox-task-tooltip')
5
+ export class OxTaskTooltip extends LitElement {
6
+ static styles = css`
7
+ :host {
8
+ position: fixed; /* position: absolute로 변경 */
9
+ background-color: var(--ox-gantt-tooltip-background-color, var(--md-sys-color-surface, #f9f9f9));
10
+ border: 1px solid var(--ox-gantt-tooltip-border-color, var(--md-sys-color-outline, #ccc));
11
+ padding: var(--spacing-medium, 10px);
12
+ border-radius: var(--spacing-medium, 8px);
13
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
14
+ visibility: hidden;
15
+ z-index: 10;
16
+ max-width: 300px;
17
+ font-family: Roboto;
18
+ font-size: 14px;
19
+ line-height: 1.6;
20
+ color: var(--ox-gantt-tooltip-color, var(--md-sys-color-on-surface, #333));
21
+ transition:
22
+ visibility 0.1s,
23
+ opacity 0.1s;
24
+ opacity: 0; /* 초기에는 보이지 않음 */
25
+ }
26
+ :host([visible]) {
27
+ visibility: visible;
28
+ opacity: 1; /* 보일 때는 불투명 */
29
+ }
30
+ .title {
31
+ font-weight: bold;
32
+ font-size: 16px;
33
+ margin-bottom: 8px;
34
+ color: var(--ox-gantt-tooltip-title-color, var(--md-sys-color-primary, #0078d4)); /* 파란색 텍스트 */
35
+ }
36
+ .detail {
37
+ display: grid;
38
+ grid-template-columns: auto auto 1fr;
39
+ gap: 4px;
40
+ align-items: center;
41
+ }
42
+ .label {
43
+ font-weight: bold;
44
+ color: var(--ox-gantt-tooltip-color, var(--md-sys-color-on-surface-variant, #333));
45
+ text-align: right; /* 라벨을 우측 정렬 */
46
+ }
47
+ .colon {
48
+ text-align: center;
49
+ color: var(--ox-gantt-tooltip-color, var(--md-sys-color-on-surface, #333));
50
+ }
51
+ .value {
52
+ text-align: left; /* 값을 좌측 정렬 */
53
+ color: var(--ox-gantt-tooltip-color, var(--md-sys-color-on-surface, #333));
54
+ }
55
+ `
56
+
57
+ @property({ type: String }) title = ''
58
+ @property({ type: String }) startDate = ''
59
+ @property({ type: String }) endDate = ''
60
+ @property({ type: Number }) progress = 0
61
+ @property({ type: String }) type = ''
62
+
63
+ show(x: number, y: number) {
64
+ const tooltipWidth = this.offsetWidth
65
+ const tooltipHeight = this.offsetHeight
66
+ const offsetX = 10
67
+ const offsetY = 10
68
+
69
+ // 기본 위치 계산
70
+ let left = x + offsetX
71
+ let top = y + offsetY
72
+
73
+ // 화면 경계 확인 및 조정
74
+ const screenWidth = window.innerWidth
75
+ const screenHeight = window.innerHeight
76
+
77
+ if (left + tooltipWidth > screenWidth) {
78
+ left = x - tooltipWidth - offsetX
79
+ }
80
+ if (top + tooltipHeight > screenHeight) {
81
+ top = y - tooltipHeight - offsetY
82
+ }
83
+
84
+ // offsetParent 요소를 기준으로 위치 설정
85
+ if (this.offsetParent) {
86
+ const parentRect = this.offsetParent!.getBoundingClientRect()
87
+ this.style.left = `${left - parentRect.left}px`
88
+ this.style.top = `${top - parentRect.top}px`
89
+ } else {
90
+ this.style.left = `${left}px`
91
+ this.style.top = `${top}px`
92
+
93
+ const myRect = this.getBoundingClientRect()
94
+
95
+ /* 실제 바운드와 설정한 left/top 이 일치하지 않는다면, 부모 어딘가에서 transform 되었다고 판단하고, 보정해줌 */
96
+ if (myRect.left !== left || myRect.top !== top) {
97
+ this.style.left = `${left + (left - myRect.left)}px`
98
+ this.style.top = `${top + (top - myRect.top)}px`
99
+ }
100
+ }
101
+
102
+ this.setAttribute('visible', '')
103
+ }
104
+
105
+ hide() {
106
+ this.removeAttribute('visible')
107
+ }
108
+
109
+ render() {
110
+ return html`
111
+ <div class="title">${this.title}</div>
112
+ <div class="detail">
113
+ <span class="label">Type</span><span class="colon">:</span><span class="value">${this.type}</span>
114
+ <span class="label">Start Date</span><span class="colon">:</span><span class="value">${this.startDate}</span>
115
+ <span class="label">End Date</span><span class="colon">:</span><span class="value">${this.endDate}</span>
116
+ <span class="label">Progress</span><span class="colon">:</span><span class="value">${this.progress}%</span>
117
+ </div>
118
+ `
119
+ }
120
+ }
package/src/types.ts CHANGED
@@ -6,10 +6,10 @@ export interface Task {
6
6
  title: string
7
7
  type: TaskType
8
8
  section?: Section
9
- startTime: Date
10
- endTime: Date
9
+ startDate: Date
10
+ endDate: Date
11
11
  progress?: number
12
- after?: string // 이전 태스크의 ID
12
+ dependsOn?: string // 이전 태스크의 ID
13
13
  length: string // 예: '3d' (3 days)
14
14
  tags?: TaskTag[]
15
15
  children?: Task[]
@@ -19,4 +19,12 @@ export interface Section {
19
19
  name: string
20
20
  }
21
21
 
22
+ export interface ColumnConfig {
23
+ id: string
24
+ label: string
25
+ visible: boolean
26
+ width: string
27
+ order: number
28
+ }
29
+
22
30
  export type LEVEL = 'year-quarter' | 'year-month' | 'quarter-week' | 'week-day'