@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.
- package/client/charts/kpi-boxplot-chart.ts +163 -0
- package/client/charts/kpi-radar-chart.ts +128 -0
- package/client/pages/kpi-dashboard/kpi-dashboard.ts +188 -0
- package/dist-client/charts/kpi-boxplot-chart.d.ts +22 -0
- package/dist-client/charts/kpi-boxplot-chart.js +198 -0
- package/dist-client/charts/kpi-boxplot-chart.js.map +1 -0
- package/dist-client/charts/kpi-radar-chart.d.ts +16 -0
- package/dist-client/charts/kpi-radar-chart.js +138 -0
- package/dist-client/charts/kpi-radar-chart.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/kpi-dashboard.d.ts +11 -0
- package/dist-client/pages/kpi-dashboard/kpi-dashboard.js +185 -0
- package/dist-client/pages/kpi-dashboard/kpi-dashboard.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
|
@@ -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
|
+
}
|