@operato/gantt 2.0.0-beta.14 → 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 (42) hide show
  1. package/CHANGELOG.md +19 -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 +134 -440
  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 +13 -1
  15. package/dist/src/ox-gantt.js +144 -27
  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.js +91 -85
  23. package/dist/stories/ox-gantt.stories.js.map +1 -1
  24. package/dist/tsconfig.tsbuildinfo +1 -1
  25. package/package.json +3 -2
  26. package/src/gantt-chart-controller.ts +470 -0
  27. package/src/locales.ts +78 -0
  28. package/src/ox-gantt-chart.ts +141 -487
  29. package/src/ox-gantt-resize-splitter.ts +18 -25
  30. package/src/ox-gantt.ts +151 -29
  31. package/src/ox-task-tooltip.ts +120 -0
  32. package/src/types.ts +11 -3
  33. package/stories/ox-gantt.stories.ts +93 -85
  34. package/translations/en.json +11 -1
  35. package/translations/ja.json +11 -1
  36. package/translations/ko.json +11 -1
  37. package/translations/ms.json +11 -1
  38. package/translations/zh.json +11 -1
  39. package/dist/src/locale.d.ts +0 -1
  40. package/dist/src/locale.js +0 -49
  41. package/dist/src/locale.js.map +0 -1
  42. 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
 
@@ -87,6 +95,50 @@ export class OxGantt extends LitElement {
87
95
  toDate: string = new Date(new Date().getFullYear(), 11, 31).toISOString().split('T')[0] /* end of this year */
88
96
  @property({ type: Boolean, attribute: 'extend-grid-lines' }) extendGridLines: boolean = false
89
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
+ ]
141
+
90
142
  @query('.container') container!: HTMLDivElement
91
143
  @query('.task-container') taskContainer!: HTMLDivElement
92
144
  @query('.chart-container') chartContainer!: HTMLDivElement
@@ -94,6 +146,7 @@ export class OxGantt extends LitElement {
94
146
  @query('ox-gantt-chart') ganttChart!: OxGanttChart
95
147
 
96
148
  private _startWidth: number = 0
149
+ private _isDragging: boolean = false
97
150
 
98
151
  render() {
99
152
  return html`
@@ -103,6 +156,7 @@ export class OxGantt extends LitElement {
103
156
  vertical
104
157
  @splitter-dragstart=${(e: CustomEvent) => this.resizeStart(e)}
105
158
  @splitter-drag=${(e: CustomEvent) => this.resizeDrag(e)}
159
+ @splitter-dragend=${(e: CustomEvent) => this.resizeEnd(e)}
106
160
  ></ox-gantt-resize-splitter>
107
161
  <ox-gantt-chart
108
162
  class="chart-container"
@@ -110,42 +164,42 @@ export class OxGantt extends LitElement {
110
164
  .fromDate=${this.fromDate}
111
165
  .toDate=${this.toDate}
112
166
  .tasks=${this.tasks}
167
+ .selectedTaskId=${this.selectedTaskId}
113
168
  axis-scale="week-day"
114
169
  ?extend-grid-lines=${this.extendGridLines}
170
+ @task-clicked=${this.handleTaskClicked}
115
171
  ></ox-gantt-chart>
116
172
  </div>
117
173
  `
118
174
  }
119
175
 
120
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
+
121
182
  return html`
122
183
  <table>
123
184
  <thead>
124
185
  <tr>
125
- <th>ID</th>
126
- <th>Section</th>
127
- <th>Title</th>
128
- <th>Type</th>
129
- <th>Start Time</th>
130
- <th>End Time</th>
131
- <th>Tags</th>
132
- <th>Progress</th>
133
- <th>Prev. Task</th>
186
+ ${sortedColumns.map(col => (col.visible ? html`<th style="width: ${col.width}">${col.label}</th>` : null))}
134
187
  </tr>
135
188
  </thead>
136
189
  <tbody>
137
- ${this.tasks.map(
138
- task => html`
139
- <tr>
140
- <td>${task.id}</td>
141
- <td>${task.section?.name || ''}</td>
142
- <td>${task.title}</td>
143
- <td>${task.type}</td>
144
- <td>${task.startTime.toISOString().split('T')[0]}</td>
145
- <td>${task.endTime.toISOString().split('T')[0]}</td>
146
- <td>${task.tags?.join(', ') || ''}</td>
147
- <td>${task.progress || ''}</td>
148
- <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
+ })}
149
203
  </tr>
150
204
  `
151
205
  )}
@@ -154,25 +208,93 @@ export class OxGantt extends LitElement {
154
208
  `
155
209
  }
156
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
+
157
267
  protected firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
158
268
  this.taskContainer.style.width = '254px'
159
269
  }
160
270
 
161
271
  updated(changes: PropertyValues<this>) {}
162
272
 
163
- syncScroll() {
164
- 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
+ }
165
280
  }
166
281
 
167
282
  resizeStart(e: CustomEvent) {
168
283
  this._startWidth = ((e.target as HTMLElement)?.previousElementSibling as HTMLElement)?.offsetWidth
284
+ this._isDragging = true
169
285
  }
170
286
 
171
287
  resizeDrag(e: CustomEvent) {
172
- var delta = e.detail
173
- var x = (e.target as HTMLElement)?.previousElementSibling as HTMLElement
174
- if (x) {
175
- 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`
176
294
  }
177
295
  }
296
+
297
+ resizeEnd(e: CustomEvent) {
298
+ this._isDragging = false
299
+ }
178
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'