@things-factory/kpi 9.0.21 → 9.0.22

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,163 @@
1
+ import { LitElement, html, css } from 'lit'
2
+ import { customElement, property } from 'lit/decorators.js'
3
+ import * as d3 from 'd3'
4
+
5
+ @customElement('kpi-boxplot-chart')
6
+ export class KpiBoxplotChart extends LitElement {
7
+ @property({ type: Array }) data: any[] = []
8
+ @property({ type: Array }) groups: string[] = []
9
+ @property({ type: String }) minKey: string = 'min'
10
+ @property({ type: String }) maxKey: string = 'max'
11
+ @property({ type: String }) meanKey: string = 'mean'
12
+ @property({ type: String }) medianKey: string = 'median'
13
+ @property({ type: String }) q1Key: string = 'q1'
14
+ @property({ type: String }) q3Key: string = 'q3'
15
+ @property({ type: String }) valueKey: string = 'value'
16
+ @property({ type: String }) currentGroup: string = ''
17
+
18
+ static styles = css`
19
+ :host {
20
+ display: block;
21
+ width: 100%;
22
+ height: 100%;
23
+ }
24
+ svg {
25
+ width: 100%;
26
+ height: 100%;
27
+ display: block;
28
+ }
29
+ `
30
+
31
+ private chartWidth = 0
32
+ private chartHeight = 0
33
+ private resizeObserver?: ResizeObserver
34
+
35
+ render() {
36
+ return html`<svg
37
+ id="boxplot"
38
+ width=${this.chartWidth}
39
+ height=${this.chartHeight}
40
+ viewBox="0 0 ${this.chartWidth} ${this.chartHeight}"
41
+ preserveAspectRatio="xMidYMid meet"
42
+ ></svg>`
43
+ }
44
+
45
+ connectedCallback() {
46
+ super.connectedCallback()
47
+ this.resizeObserver = new ResizeObserver(entries => {
48
+ for (const entry of entries) {
49
+ const rect = entry.contentRect
50
+ this.chartWidth = rect.width
51
+ this.chartHeight = rect.height
52
+ this.requestUpdate()
53
+ }
54
+ })
55
+ this.resizeObserver.observe(this)
56
+ }
57
+
58
+ disconnectedCallback() {
59
+ this.resizeObserver?.disconnect()
60
+ super.disconnectedCallback()
61
+ }
62
+
63
+ updated() {
64
+ this.drawBoxplot()
65
+ }
66
+
67
+ drawBoxplot() {
68
+ const svg = d3.select(this.renderRoot.querySelector('#boxplot'))
69
+ svg.selectAll('*').remove()
70
+ const w = this.chartWidth || 300
71
+ const h = this.chartHeight || 300
72
+ const margin = { top: 20, right: 20, bottom: 40, left: 40 }
73
+ const plotW = w - margin.left - margin.right
74
+ const plotH = h - margin.top - margin.bottom
75
+
76
+ // x축: 그룹
77
+ const x = d3.scaleBand().domain(this.groups).range([0, plotW]).padding(0.4)
78
+ // y축: 값
79
+ const allValues = this.data.flatMap(d => [
80
+ d[this.minKey],
81
+ d[this.maxKey],
82
+ d[this.q1Key],
83
+ d[this.q3Key],
84
+ d[this.medianKey],
85
+ d[this.meanKey],
86
+ d[this.valueKey]
87
+ ])
88
+ const y = d3
89
+ .scaleLinear()
90
+ .domain([d3.min(allValues) ?? 0, d3.max(allValues) ?? 1])
91
+ .nice()
92
+ .range([plotH, 0])
93
+
94
+ const g = svg
95
+ .attr('width', w)
96
+ .attr('height', h)
97
+ .append('g')
98
+ .attr('transform', `translate(${margin.left},${margin.top})`)
99
+
100
+ // 축
101
+ g.append('g').call(d3.axisLeft(y))
102
+ g.append('g').attr('transform', `translate(0,${plotH})`).call(d3.axisBottom(x))
103
+
104
+ // 박스플롯
105
+ this.data.forEach(d => {
106
+ const gx = x(d.group) ?? 0
107
+ // 박스
108
+ g.append('rect')
109
+ .attr('x', gx)
110
+ .attr('y', y(d[this.q3Key]))
111
+ .attr('width', x.bandwidth())
112
+ .attr('height', y(d[this.q1Key]) - y(d[this.q3Key]))
113
+ .attr('fill', d.group === this.currentGroup ? '#2196f3' : '#bbb')
114
+ .attr('opacity', 0.5)
115
+ // 중앙선(중앙값)
116
+ g.append('line')
117
+ .attr('x1', gx)
118
+ .attr('x2', gx + x.bandwidth())
119
+ .attr('y1', y(d[this.medianKey]))
120
+ .attr('y2', y(d[this.medianKey]))
121
+ .attr('stroke', '#333')
122
+ .attr('stroke-width', 2)
123
+ // 수염(min-max)
124
+ g.append('line')
125
+ .attr('x1', gx + x.bandwidth() / 2)
126
+ .attr('x2', gx + x.bandwidth() / 2)
127
+ .attr('y1', y(d[this.minKey]))
128
+ .attr('y2', y(d[this.maxKey]))
129
+ .attr('stroke', '#333')
130
+ // min/max
131
+ g.append('line')
132
+ .attr('x1', gx + x.bandwidth() / 4)
133
+ .attr('x2', gx + (x.bandwidth() * 3) / 4)
134
+ .attr('y1', y(d[this.minKey]))
135
+ .attr('y2', y(d[this.minKey]))
136
+ .attr('stroke', '#333')
137
+ g.append('line')
138
+ .attr('x1', gx + x.bandwidth() / 4)
139
+ .attr('x2', gx + (x.bandwidth() * 3) / 4)
140
+ .attr('y1', y(d[this.maxKey]))
141
+ .attr('y2', y(d[this.maxKey]))
142
+ .attr('stroke', '#333')
143
+ // 평균값
144
+ g.append('circle')
145
+ .attr('cx', gx + x.bandwidth() / 2)
146
+ .attr('cy', y(d[this.meanKey]))
147
+ .attr('r', 4)
148
+ .attr('fill', 'orange')
149
+ })
150
+ // 현재 그룹 값 강조
151
+ this.data.forEach(d => {
152
+ if (d.group === this.currentGroup) {
153
+ g.append('circle')
154
+ .attr('cx', x(d.group) + x.bandwidth() / 2)
155
+ .attr('cy', y(d[this.valueKey]))
156
+ .attr('r', 6)
157
+ .attr('fill', '#e91e63')
158
+ .attr('stroke', '#fff')
159
+ .attr('stroke-width', 2)
160
+ }
161
+ })
162
+ }
163
+ }
@@ -0,0 +1,128 @@
1
+ import { LitElement, html, css } from 'lit'
2
+ import { customElement, property } from 'lit/decorators.js'
3
+ import * as d3 from 'd3'
4
+
5
+ @customElement('kpi-radar-chart')
6
+ export class KpiRadarChart extends LitElement {
7
+ @property({ type: Array }) data: any[] = []
8
+ @property({ type: Array }) categories: string[] = []
9
+ @property({ type: String }) valueKey: string = 'value'
10
+ @property({ type: String }) currentGroup: string = ''
11
+
12
+ static styles = css`
13
+ :host {
14
+ display: block;
15
+ width: 100%;
16
+ height: 100%;
17
+ }
18
+ svg {
19
+ width: 100%;
20
+ height: 100%;
21
+ display: block;
22
+ }
23
+ `
24
+
25
+ private chartWidth = 0
26
+ private chartHeight = 0
27
+ private resizeObserver?: ResizeObserver
28
+
29
+ render() {
30
+ return html`<svg
31
+ id="radar"
32
+ width=${this.chartWidth}
33
+ height=${this.chartHeight}
34
+ viewBox="0 0 ${this.chartWidth} ${this.chartHeight}"
35
+ preserveAspectRatio="xMidYMid meet"
36
+ ></svg>`
37
+ }
38
+
39
+ connectedCallback() {
40
+ super.connectedCallback()
41
+ this.resizeObserver = new ResizeObserver(entries => {
42
+ for (const entry of entries) {
43
+ const rect = entry.contentRect
44
+ this.chartWidth = rect.width
45
+ this.chartHeight = rect.height
46
+ this.requestUpdate()
47
+ }
48
+ })
49
+ this.resizeObserver.observe(this)
50
+ }
51
+
52
+ disconnectedCallback() {
53
+ this.resizeObserver?.disconnect()
54
+ super.disconnectedCallback()
55
+ }
56
+
57
+ updated() {
58
+ this.drawRadar()
59
+ }
60
+
61
+ drawRadar() {
62
+ const svg = d3.select(this.renderRoot.querySelector('#radar'))
63
+ svg.selectAll('*').remove()
64
+ const w = this.chartWidth || 300
65
+ const h = this.chartHeight || 300
66
+ const r = Math.min(w, h) / 2 - 40
67
+ const angleSlice = (2 * Math.PI) / (this.categories.length || 1)
68
+
69
+ // 데이터 변환: { group, values: [ {category, value} ... ] }
70
+ const groupData = d3
71
+ .groups(this.data, d => d.group)
72
+ .map(([group, values]) => ({
73
+ group,
74
+ values: this.categories.map((cat, i) => {
75
+ const found = values.find(v => v.category === cat)
76
+ return { category: cat, value: found ? found[this.valueKey] : 0 }
77
+ })
78
+ }))
79
+
80
+ // 스케일
81
+ const maxValue = d3.max(this.data, d => d[this.valueKey]) || 1
82
+ const radius = d3.scaleLinear().domain([0, maxValue]).range([0, r])
83
+
84
+ // SVG 기본
85
+ svg.attr('width', w).attr('height', h)
86
+ const g = svg.append('g').attr('transform', `translate(${w / 2},${h / 2})`)
87
+
88
+ // 그리드/축
89
+ for (let i = 1; i <= 5; i++) {
90
+ g.append('circle')
91
+ .attr('r', (r / 5) * i)
92
+ .attr('fill', 'none')
93
+ .attr('stroke', '#ccc')
94
+ }
95
+ this.categories.forEach((cat, i) => {
96
+ const angle = i * angleSlice - Math.PI / 2
97
+ g.append('line')
98
+ .attr('x1', 0)
99
+ .attr('y1', 0)
100
+ .attr('x2', radius(maxValue) * Math.cos(angle))
101
+ .attr('y2', radius(maxValue) * Math.sin(angle))
102
+ .attr('stroke', '#ccc')
103
+ g.append('text')
104
+ .attr('x', (radius(maxValue) + 10) * Math.cos(angle))
105
+ .attr('y', (radius(maxValue) + 10) * Math.sin(angle))
106
+ .attr('text-anchor', 'middle')
107
+ .attr('alignment-baseline', 'middle')
108
+ .attr('font-size', 12)
109
+ .text(cat)
110
+ })
111
+
112
+ // 그룹별 폴리곤
113
+ groupData.forEach(gd => {
114
+ // 마지막에 첫 점을 한 번 더 추가
115
+ const closedValues = [...gd.values, gd.values[0]]
116
+ const line = d3
117
+ .lineRadial()
118
+ .radius((d: any) => radius(d.value))
119
+ .angle((d, i) => i * angleSlice)
120
+ g.append('path')
121
+ .datum(closedValues)
122
+ .attr('d', line as any)
123
+ .attr('fill', gd.group === this.currentGroup ? 'rgba(33,150,243,0.4)' : 'rgba(200,200,200,0.2)')
124
+ .attr('stroke', gd.group === this.currentGroup ? '#2196f3' : '#aaa')
125
+ .attr('stroke-width', gd.group === this.currentGroup ? 3 : 1)
126
+ })
127
+ }
128
+ }
@@ -13,6 +13,8 @@ import './kpi-history-viewer'
13
13
  import './kpi-list-summary'
14
14
  import './kpi-value-entry'
15
15
  import './kpi-alert-panel'
16
+ import '../../charts/kpi-radar-chart'
17
+ import '../../charts/kpi-boxplot-chart'
16
18
 
17
19
  @customElement('kpi-dashboard')
18
20
  export class KpiDashboardPage extends PageView {
@@ -28,6 +30,35 @@ export class KpiDashboardPage extends PageView {
28
30
  flex: 1;
29
31
  padding: 24px;
30
32
  }
33
+ .sample-charts-section {
34
+ display: flex;
35
+ gap: 40px;
36
+ margin-bottom: 48px;
37
+ align-items: flex-start;
38
+ }
39
+ .sample-chart-card {
40
+ background: #fff;
41
+ border-radius: 12px;
42
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
43
+ border: 1px solid #ececec;
44
+ padding: 24px 32px;
45
+ min-width: 340px;
46
+ flex: 1;
47
+ display: flex;
48
+ flex-direction: column;
49
+ align-items: stretch;
50
+ min-height: 500px;
51
+ }
52
+ .sample-chart-title {
53
+ font-size: 1.1rem;
54
+ font-weight: bold;
55
+ margin-bottom: 12px;
56
+ }
57
+ .sample-chart-container {
58
+ width: 100%;
59
+ height: 340px;
60
+ min-height: 340px;
61
+ }
31
62
  .category-section {
32
63
  margin-bottom: 40px;
33
64
  }
@@ -79,6 +110,127 @@ export class KpiDashboardPage extends PageView {
79
110
  @state() modalHistories: any[] = []
80
111
  @state() modalKpiName: string = ''
81
112
 
113
+ // 샘플 데이터
114
+ private get sampleCategories(): string[] {
115
+ return ['생산성', '품질', '안전', '환경', '비용', '일정']
116
+ }
117
+ private get sampleGroups(): string[] {
118
+ return ['A', 'B', 'C']
119
+ }
120
+ private get sampleSeriesData(): any[] {
121
+ // 각 카테고리별로 10개 이상의 다양한 값(평균, 분산, 이상치 포함)
122
+ // 생산성: 평균 높고 분산 큼, 이상치 포함
123
+ const 생산성 = [95, 92, 90, 88, 85, 80, 78, 75, 70, 60, 100] // 100은 이상치
124
+ // 품질: 평균 높고 분산 작음
125
+ const 품질 = [90, 89, 88, 87, 86, 85, 84, 83, 82, 80, 70]
126
+ // 안전: 평균 중간, 이상치 포함
127
+ const 안전 = [92, 91, 90, 89, 88, 87, 86, 85, 84, 65, 60] // 60은 이상치
128
+ // 환경: 낮은 값에 몰림, 분산 큼
129
+ const 환경 = [95, 90, 85, 80, 75, 70, 65, 60, 60, 60, 55]
130
+ // 비용: 전체적으로 낮음, 이상치 포함
131
+ const 비용 = [80, 78, 76, 74, 72, 70, 68, 66, 64, 62, 50] // 50은 이상치
132
+ // 일정: 분산 큼, 이상치 포함
133
+ const 일정 = [90, 88, 86, 84, 82, 80, 78, 76, 74, 60, 100] // 100은 이상치
134
+
135
+ const categories = this.sampleCategories
136
+ const groups = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K']
137
+ const all: any[] = []
138
+ categories.forEach((cat, i) => {
139
+ let arr: number[] = []
140
+ switch (cat) {
141
+ case '생산성':
142
+ arr = 생산성
143
+ break
144
+ case '품질':
145
+ arr = 품질
146
+ break
147
+ case '안전':
148
+ arr = 안전
149
+ break
150
+ case '환경':
151
+ arr = 환경
152
+ break
153
+ case '비용':
154
+ arr = 비용
155
+ break
156
+ case '일정':
157
+ arr = 일정
158
+ break
159
+ }
160
+ arr.forEach((v, idx) => {
161
+ all.push({ group: groups[idx] ?? `G${idx + 1}`, category: cat, value: v })
162
+ })
163
+ })
164
+ return all
165
+ }
166
+ private get sampleRadarData(): any[] {
167
+ // 카테고리별로 min, max, avg, A(자신의 그룹)만 반환
168
+ const categories = this.sampleCategories
169
+ const data = this.sampleSeriesData
170
+ const minData: any = { group: 'Min' }
171
+ const maxData: any = { group: 'Max' }
172
+ const avgData: any = { group: 'Avg' }
173
+ const aData: any = { group: 'A' }
174
+ categories.forEach(category => {
175
+ const values = data.filter(d => d.category === category).map(d => d.value)
176
+ minData[category] = Math.min(...values)
177
+ maxData[category] = Math.max(...values)
178
+ avgData[category] = values.reduce((a, b) => a + b, 0) / values.length
179
+ aData[category] = data.find(d => d.category === category && d.group === 'A')?.value ?? avgData[category]
180
+ })
181
+ // kpi-radar-chart가 기대하는 구조로 변환: [{group, category, value} ...]
182
+ const result: any[] = []
183
+ categories.forEach(category => {
184
+ result.push({ group: 'Min', category, value: minData[category] })
185
+ result.push({ group: 'Max', category, value: maxData[category] })
186
+ result.push({ group: 'Avg', category, value: avgData[category] })
187
+ result.push({ group: 'A', category, value: aData[category] })
188
+ })
189
+ return result
190
+ }
191
+ private get sampleRadarCategories(): string[] {
192
+ return this.sampleCategories
193
+ }
194
+ private get sampleRadarGroups(): string[] {
195
+ return this.sampleGroups
196
+ }
197
+ private get sampleBoxplotData(): any[] {
198
+ // 카테고리별로 그룹별 value의 분포 계산
199
+ const categories = this.sampleCategories
200
+ const data = this.sampleSeriesData
201
+ return categories.map(category => {
202
+ const values = data.filter(d => d.category === category).map(d => d.value)
203
+ const sorted = [...values].sort((a, b) => a - b)
204
+ const min = sorted[0]
205
+ const max = sorted[sorted.length - 1]
206
+ const mean = values.reduce((a, b) => a + b, 0) / values.length
207
+ const median =
208
+ sorted.length % 2 === 0
209
+ ? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2
210
+ : sorted[Math.floor(sorted.length / 2)]
211
+ const q1 = sorted[Math.floor(sorted.length / 4)]
212
+ const q3 = sorted[Math.floor((sorted.length * 3) / 4)]
213
+ // value: A그룹의 실제값(강조)
214
+ const value = data.find(d => d.category === category && d.group === 'A')?.value ?? mean
215
+ return {
216
+ group: category, // x축: 카테고리
217
+ min,
218
+ q1,
219
+ median,
220
+ q3,
221
+ max,
222
+ mean,
223
+ value
224
+ }
225
+ })
226
+ }
227
+ private get sampleBoxplotGroups(): string[] {
228
+ return this.sampleCategories
229
+ }
230
+ private get sampleCurrentGroup() {
231
+ return 'A'
232
+ }
233
+
82
234
  connectedCallback() {
83
235
  super.connectedCallback()
84
236
  this.fetchCategories()
@@ -313,6 +465,42 @@ export class KpiDashboardPage extends PageView {
313
465
  if (this.error) return html`<div>${this.error}</div>`
314
466
  return html`
315
467
  <div class="dashboard-root">
468
+ <!-- 샘플 차트 섹션 -->
469
+ <div class="sample-charts-section">
470
+ <div class="sample-chart-card">
471
+ <div class="sample-chart-title">그룹별 KPI 비교 (Radar)</div>
472
+ <div class="sample-chart-container">
473
+ <kpi-radar-chart
474
+ .data=${this.sampleRadarData ?? []}
475
+ .categories=${this.sampleRadarCategories ?? []}
476
+ .currentGroup=${this.sampleCurrentGroup}
477
+ ></kpi-radar-chart>
478
+ </div>
479
+ <div style="margin-top:12px;color:#666;font-size:0.98em;">
480
+ ※ <b>Radar 차트</b>는 각 카테고리별로 최소(Min), 최대(Max), 평균(Avg), 그리고 이 프로젝트의 값을 한눈에
481
+ 비교할 수 있도록 시각화합니다.<br />
482
+ <b>진한 파란색</b> 다각형이 이 프로젝트의 성과이며, 회색 다각형은 기준값(최소/최대/평균)입니다.
483
+ </div>
484
+ </div>
485
+ <div class="sample-chart-card">
486
+ <div class="sample-chart-title">그룹별 분포 (Boxplot)</div>
487
+ <div class="sample-chart-container">
488
+ <kpi-boxplot-chart
489
+ .data=${this.sampleBoxplotData ?? []}
490
+ .groups=${this.sampleBoxplotGroups ?? []}
491
+ .currentGroup=${this.sampleCurrentGroup}
492
+ ></kpi-boxplot-chart>
493
+ </div>
494
+ <div style="margin-top:12px;color:#666;font-size:0.98em;">
495
+ ※ <b>Boxplot(박스플롯)</b>은 각 카테고리별로 값의 분포(최소, 1사분위, 중앙값, 3사분위, 최대, 평균, 이상치
496
+ 등)를 보여줍니다.<br />
497
+ <b>박스</b>는 중앙 50% 구간, <b>수염</b>은 전체 범위, <b>굵은 검정색 가로선</b>은 중앙값(메디안),
498
+ <b>주황색 원</b>은 평균값(Mean)을 의미합니다.<br />
499
+ <b>이 프로젝트의 값</b>은 <b>진한 오렌지색 원</b>으로 별도 강조되어 표시되며, 중앙값/평균과 다를 수
500
+ 있습니다.<br />
501
+ </div>
502
+ </div>
503
+ </div>
316
504
  ${this.showHistoryModal
317
505
  ? html`
318
506
  <div
@@ -0,0 +1,22 @@
1
+ import { LitElement } from 'lit';
2
+ export declare class KpiBoxplotChart extends LitElement {
3
+ data: any[];
4
+ groups: string[];
5
+ minKey: string;
6
+ maxKey: string;
7
+ meanKey: string;
8
+ medianKey: string;
9
+ q1Key: string;
10
+ q3Key: string;
11
+ valueKey: string;
12
+ currentGroup: string;
13
+ static styles: import("lit").CSSResult;
14
+ private chartWidth;
15
+ private chartHeight;
16
+ private resizeObserver?;
17
+ render(): import("lit-html").TemplateResult<1>;
18
+ connectedCallback(): void;
19
+ disconnectedCallback(): void;
20
+ updated(): void;
21
+ drawBoxplot(): void;
22
+ }