@dssp/dkpi 1.0.0-alpha.67 → 1.0.0-alpha.69
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/dist-client/components/kpi-2d-lookup-chart.d.ts +10 -30
- package/dist-client/components/kpi-2d-lookup-chart.js +145 -362
- package/dist-client/components/kpi-2d-lookup-chart.js.map +1 -1
- package/dist-client/components/kpi-boxplot-chart.js +51 -24
- package/dist-client/components/kpi-boxplot-chart.js.map +1 -1
- package/dist-client/components/kpi-lookup-chart.d.ts +28 -4
- package/dist-client/components/kpi-lookup-chart.js +314 -293
- package/dist-client/components/kpi-lookup-chart.js.map +1 -1
- package/dist-client/components/kpi-radar-chart.js +29 -5
- package/dist-client/components/kpi-radar-chart.js.map +1 -1
- package/dist-client/components/kpi-single-boxplot-chart.js +72 -14
- package/dist-client/components/kpi-single-boxplot-chart.js.map +1 -1
- package/dist-client/pages/kpi-admin/dssp-kpi-overview.d.ts +46 -0
- package/dist-client/pages/kpi-admin/dssp-kpi-overview.js +378 -0
- package/dist-client/pages/kpi-admin/dssp-kpi-overview.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js +0 -9
- package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js.map +1 -1
- package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js +0 -1
- package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js.map +1 -1
- package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js +0 -4
- package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js.map +1 -1
- package/dist-client/pages/kpi-metric-value/kpi-metric-value-editor-page.js +0 -11
- package/dist-client/pages/kpi-metric-value/kpi-metric-value-editor-page.js.map +1 -1
- package/dist-client/pages/sv-project-completed-list.js +0 -1
- package/dist-client/pages/sv-project-completed-list.js.map +1 -1
- package/dist-client/pages/sv-project-detail.d.ts +11 -1
- package/dist-client/pages/sv-project-detail.js +150 -20
- package/dist-client/pages/sv-project-detail.js.map +1 -1
- package/dist-client/pages/sv-project-list.js +0 -1
- package/dist-client/pages/sv-project-list.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/route.js +3 -0
- package/dist-client/route.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/schema.graphql +26 -5
- package/things-factory.config.js +1 -0
|
@@ -9,6 +9,11 @@ let KpiLookupChart = class KpiLookupChart extends LitElement {
|
|
|
9
9
|
this.value = null;
|
|
10
10
|
this.unit = '';
|
|
11
11
|
this.kpiName = '';
|
|
12
|
+
this.showDots = false;
|
|
13
|
+
/** KPI scoreType: 'DIRECT' | 'FORMULA' | 'LOOKUP' | 'CUSTOM' | '' */
|
|
14
|
+
this.scoreType = '';
|
|
15
|
+
/** KPI valueType: 'MEASURED' | 'ASSESSED' | 'CALCULATED' | 'COMPOSITE' | '' */
|
|
16
|
+
this.valueType = '';
|
|
12
17
|
this.chartWidth = 0;
|
|
13
18
|
this.chartHeight = 0;
|
|
14
19
|
}
|
|
@@ -43,316 +48,338 @@ let KpiLookupChart = class KpiLookupChart extends LitElement {
|
|
|
43
48
|
updated() {
|
|
44
49
|
this.renderChart();
|
|
45
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* 1~5 평가형 KPI인지 판별 — 게이지로 표시할지 결정
|
|
53
|
+
*
|
|
54
|
+
* valueType/scoreType 기반 판별 (우선):
|
|
55
|
+
* valueType='ASSESSED' → 평가형 (감리자 직접 입력, 게이지)
|
|
56
|
+
* scoreType='LOOKUP'/'CUSTOM' → 분포 차트
|
|
57
|
+
*
|
|
58
|
+
* 미설정 시 폴백: grades 구조로 판별
|
|
59
|
+
*/
|
|
60
|
+
isAssessmentKpi() {
|
|
61
|
+
if (this.valueType === 'ASSESSED')
|
|
62
|
+
return true;
|
|
63
|
+
if (this.scoreType === 'LOOKUP' || this.scoreType === 'CUSTOM')
|
|
64
|
+
return false;
|
|
65
|
+
// 폴백: grades 구조로 판별
|
|
66
|
+
if (!this.grades || !Array.isArray(this.grades) || this.grades.length === 0)
|
|
67
|
+
return true;
|
|
68
|
+
return (this.grades.length <= 5 &&
|
|
69
|
+
this.grades.every((g) => g.score >= 1 && g.score <= 5 && g.score === Math.floor(g.score) && g.minValue === g.score));
|
|
70
|
+
}
|
|
46
71
|
renderChart() {
|
|
47
72
|
const svg = d3.select(this.renderRoot.querySelector('#lookup-chart'));
|
|
48
73
|
svg.selectAll('*').remove();
|
|
49
|
-
//
|
|
50
|
-
if (!this.grades || this.
|
|
51
|
-
this.
|
|
74
|
+
// grades가 배열이 아닌 경우 (2D lookup 객체 등) 또는 1~5 평가형 → 세그먼트 바
|
|
75
|
+
if (!Array.isArray(this.grades) || this.isAssessmentKpi()) {
|
|
76
|
+
this.renderAssessmentGauge(svg);
|
|
52
77
|
return;
|
|
53
78
|
}
|
|
54
79
|
const w = this.chartWidth || 400;
|
|
55
80
|
const h = this.chartHeight || 200;
|
|
56
|
-
const margin = { top:
|
|
81
|
+
const margin = { top: 20, right: 30, bottom: 45, left: 55 };
|
|
57
82
|
const plotW = w - margin.left - margin.right;
|
|
58
83
|
const plotH = h - margin.top - margin.bottom;
|
|
84
|
+
if (plotW <= 0 || plotH <= 0)
|
|
85
|
+
return;
|
|
59
86
|
const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`);
|
|
60
|
-
// 제목
|
|
61
|
-
if (this.kpiName) {
|
|
62
|
-
svg
|
|
63
|
-
.append('text')
|
|
64
|
-
.attr('class', 'chart-title')
|
|
65
|
-
.attr('x', w / 2)
|
|
66
|
-
.attr('y', 15)
|
|
67
|
-
.attr('text-anchor', 'middle')
|
|
68
|
-
.text(this.kpiName);
|
|
69
|
-
}
|
|
70
|
-
// KPI Value와 Score를 차트 상단에 표시
|
|
71
|
-
this.drawHeader(svg, w);
|
|
72
|
-
// grades를 정렬 (minValue 기준)
|
|
73
87
|
const sortedGrades = [...this.grades].sort((a, b) => a.minValue - b.minValue);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
//
|
|
88
|
+
// 중간값 포인트 생성 (maxValue >= 99999인 마지막 구간 제외)
|
|
89
|
+
const points = this.buildMidpoints(sortedGrades);
|
|
90
|
+
if (points.length === 0)
|
|
91
|
+
return;
|
|
92
|
+
// 스케일 — grade의 minValue~maxValue 범위, 마지막 grade의 큰 maxValue(999, 99999 등) 제외
|
|
93
|
+
const allMaxValues = sortedGrades.map(g => g.maxValue).sort((a, b) => a - b);
|
|
94
|
+
const secondLargestMax = allMaxValues.length > 1 ? allMaxValues[allMaxValues.length - 2] : allMaxValues[0];
|
|
95
|
+
const lastGradeMin = sortedGrades[sortedGrades.length - 1].minValue;
|
|
96
|
+
let xMin = sortedGrades[0].minValue;
|
|
97
|
+
let xMax = Math.max(secondLargestMax, lastGradeMin);
|
|
79
98
|
if (this.value !== null && this.value !== undefined) {
|
|
80
|
-
|
|
81
|
-
.append('text')
|
|
82
|
-
.attr('class', 'value-label')
|
|
83
|
-
.attr('x', 10)
|
|
84
|
-
.attr('y', headerY)
|
|
85
|
-
.attr('font-size', '12px')
|
|
86
|
-
.attr('font-weight', 'bold')
|
|
87
|
-
.text(`KPI Value: ${this.value.toFixed(2)}${this.unit}`);
|
|
99
|
+
xMax = Math.max(xMax, this.value);
|
|
88
100
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
const xPad = (xMax - xMin) * 0.15 || 0.5;
|
|
102
|
+
const xScale = d3.scaleLinear().domain([xMin - xPad, xMax + xPad]).range([0, plotW]).nice();
|
|
103
|
+
const yExtent = d3.extent(points, d => d.score);
|
|
104
|
+
const [yMin, yMax] = yExtent;
|
|
105
|
+
const yPad = (yMax - yMin) * 0.08 || 0.1;
|
|
106
|
+
const yScale = d3.scaleLinear().domain([yMin - yPad, yMax + yPad]).range([plotH, 0]).nice();
|
|
107
|
+
// 스코어 구간 배경 (연한 색상)
|
|
108
|
+
const scoreColors = ['#e8f5e9', '#f1f8e9', '#fff9c4', '#fff3e0', '#fce4ec'];
|
|
109
|
+
const uniqueScores = [...new Set(points.map(p => p.score))].sort((a, b) => b - a);
|
|
110
|
+
uniqueScores.forEach((score, i) => {
|
|
111
|
+
const yPos = yScale(score);
|
|
112
|
+
const nextScore = i < uniqueScores.length - 1 ? uniqueScores[i + 1] : yMin - yPad;
|
|
113
|
+
const yNext = yScale(nextScore);
|
|
114
|
+
g.append('rect')
|
|
115
|
+
.attr('x', 0)
|
|
116
|
+
.attr('y', Math.min(yPos, yNext))
|
|
117
|
+
.attr('width', plotW)
|
|
118
|
+
.attr('height', Math.abs(yNext - yPos))
|
|
119
|
+
.attr('fill', scoreColors[i % scoreColors.length])
|
|
120
|
+
.attr('opacity', 0.5);
|
|
121
|
+
});
|
|
122
|
+
// X축 범위 내 포인트만 사용
|
|
123
|
+
const xDomainMax = xMax + xPad;
|
|
124
|
+
const visiblePoints = points.filter(p => p.x <= xDomainMax);
|
|
125
|
+
// 스코어 커브 (중간값 연결)
|
|
126
|
+
const line = d3
|
|
127
|
+
.line()
|
|
128
|
+
.x(d => xScale(d.x))
|
|
129
|
+
.y(d => yScale(d.score))
|
|
130
|
+
.curve(d3.curveMonotoneX);
|
|
131
|
+
g.append('path')
|
|
132
|
+
.datum(visiblePoints)
|
|
133
|
+
.attr('d', line)
|
|
134
|
+
.attr('fill', 'none')
|
|
135
|
+
.attr('stroke', '#1976d2')
|
|
136
|
+
.attr('stroke-width', 2.5);
|
|
137
|
+
// 도트 + 툴팁 (showDots 모드)
|
|
138
|
+
if (this.showDots && visiblePoints.length > 0) {
|
|
139
|
+
const dotTooltip = svg.append('g').attr('class', 'dot-tooltip').attr('visibility', 'hidden');
|
|
140
|
+
dotTooltip.append('rect').attr('rx', 4).attr('ry', 4).attr('fill', 'rgba(33,33,33,0.9)');
|
|
141
|
+
dotTooltip.append('text').attr('fill', '#fff').attr('font-size', '10px');
|
|
142
|
+
const showDotTip = (evt, lines) => {
|
|
143
|
+
const tipText = dotTooltip.select('text');
|
|
144
|
+
tipText.selectAll('tspan').remove();
|
|
145
|
+
lines.forEach((ln, i) => {
|
|
146
|
+
tipText.append('tspan').attr('x', 8).attr('dy', i === 0 ? 14 : 13).text(ln);
|
|
147
|
+
});
|
|
148
|
+
const tipW = Math.max(...lines.map(l => l.length)) * 8 + 24;
|
|
149
|
+
const tipH = lines.length * 13 + 10;
|
|
150
|
+
const [mx, my] = d3.pointer(evt, svg.node());
|
|
151
|
+
const tx = Math.min(mx + 12, w - tipW - 5);
|
|
152
|
+
const ty = Math.max(my - tipH - 8, 5);
|
|
153
|
+
dotTooltip.select('rect').attr('x', tx).attr('y', ty).attr('width', tipW).attr('height', tipH);
|
|
154
|
+
tipText.attr('transform', `translate(${tx},${ty})`);
|
|
155
|
+
dotTooltip.attr('visibility', 'visible').raise();
|
|
156
|
+
};
|
|
157
|
+
const hideDotTip = () => dotTooltip.attr('visibility', 'hidden');
|
|
158
|
+
// 가이드 라인 (hover 시 표시/숨김)
|
|
159
|
+
const guideLineX = g.append('line')
|
|
160
|
+
.attr('stroke', '#1976d2').attr('stroke-width', 1).attr('stroke-dasharray', '3,3')
|
|
161
|
+
.attr('opacity', 0.5).attr('visibility', 'hidden');
|
|
162
|
+
const guideLineY = g.append('line')
|
|
163
|
+
.attr('stroke', '#1976d2').attr('stroke-width', 1).attr('stroke-dasharray', '3,3')
|
|
164
|
+
.attr('opacity', 0.5).attr('visibility', 'hidden');
|
|
165
|
+
visiblePoints.forEach(pt => {
|
|
166
|
+
const cx = xScale(pt.x);
|
|
167
|
+
const cy = yScale(pt.score);
|
|
168
|
+
g.append('circle')
|
|
169
|
+
.attr('cx', cx)
|
|
170
|
+
.attr('cy', cy)
|
|
171
|
+
.attr('r', 4)
|
|
172
|
+
.attr('fill', '#1976d2')
|
|
173
|
+
.attr('stroke', '#fff')
|
|
174
|
+
.attr('stroke-width', 1.5)
|
|
175
|
+
.style('cursor', 'pointer')
|
|
176
|
+
.on('mouseenter', (evt) => {
|
|
177
|
+
guideLineX.attr('x1', cx).attr('x2', cx).attr('y1', cy).attr('y2', plotH).attr('visibility', 'visible');
|
|
178
|
+
guideLineY.attr('x1', 0).attr('x2', cx).attr('y1', cy).attr('y2', cy).attr('visibility', 'visible');
|
|
179
|
+
const rangeText = pt.isLast
|
|
180
|
+
? `구간: ${pt.rangeMin.toFixed(4)}${this.unit} 이상`
|
|
181
|
+
: `구간: ${pt.rangeMin.toFixed(4)} ~ ${pt.rangeMax.toFixed(4)}${this.unit}`;
|
|
182
|
+
showDotTip(evt, [
|
|
183
|
+
rangeText,
|
|
184
|
+
`성과 점수: ${pt.score.toFixed(4)}`
|
|
185
|
+
]);
|
|
186
|
+
})
|
|
187
|
+
.on('mouseleave', () => {
|
|
188
|
+
guideLineX.attr('visibility', 'hidden');
|
|
189
|
+
guideLineY.attr('visibility', 'hidden');
|
|
190
|
+
hideDotTip();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
99
193
|
}
|
|
100
|
-
//
|
|
194
|
+
// 축
|
|
195
|
+
g.append('g')
|
|
196
|
+
.attr('transform', `translate(0,${plotH})`)
|
|
197
|
+
.call(d3.axisBottom(xScale).ticks(Math.min(points.length, 8)))
|
|
198
|
+
.selectAll('text')
|
|
199
|
+
.attr('font-size', '10px');
|
|
200
|
+
g.append('g').call(d3.axisLeft(yScale).ticks(5)).selectAll('text').attr('font-size', '10px');
|
|
201
|
+
// 축 라벨
|
|
202
|
+
g.append('text')
|
|
203
|
+
.attr('x', plotW / 2)
|
|
204
|
+
.attr('y', plotH + 35)
|
|
205
|
+
.attr('text-anchor', 'middle')
|
|
206
|
+
.attr('font-size', '11px')
|
|
207
|
+
.attr('fill', '#666')
|
|
208
|
+
.text(`측정값${this.unit ? ` (${this.unit})` : ''}`);
|
|
209
|
+
g.append('text')
|
|
210
|
+
.attr('transform', 'rotate(-90)')
|
|
211
|
+
.attr('x', -plotH / 2)
|
|
212
|
+
.attr('y', -40)
|
|
213
|
+
.attr('text-anchor', 'middle')
|
|
214
|
+
.attr('font-size', '11px')
|
|
215
|
+
.attr('fill', '#666')
|
|
216
|
+
.text('성과 점수');
|
|
217
|
+
// 현재 값 표시
|
|
101
218
|
if (this.value !== null && this.value !== undefined) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
219
|
+
const currentScore = this.interpolateScore(points, this.value);
|
|
220
|
+
if (currentScore !== null) {
|
|
221
|
+
const cx = xScale(this.value);
|
|
222
|
+
const cy = yScale(currentScore);
|
|
223
|
+
// 가이드 라인
|
|
224
|
+
g.append('line')
|
|
225
|
+
.attr('x1', cx).attr('x2', cx)
|
|
226
|
+
.attr('y1', cy).attr('y2', plotH)
|
|
227
|
+
.attr('stroke', '#e53935').attr('stroke-width', 1).attr('stroke-dasharray', '3,3');
|
|
228
|
+
g.append('line')
|
|
229
|
+
.attr('x1', 0).attr('x2', cx)
|
|
230
|
+
.attr('y1', cy).attr('y2', cy)
|
|
231
|
+
.attr('stroke', '#e53935').attr('stroke-width', 1).attr('stroke-dasharray', '3,3');
|
|
232
|
+
// 현재 값 포인트 + 툴팁
|
|
233
|
+
const tooltip = svg.append('g')
|
|
234
|
+
.attr('class', 'tooltip-group')
|
|
235
|
+
.attr('visibility', 'hidden');
|
|
236
|
+
const tipRect = tooltip.append('rect')
|
|
237
|
+
.attr('rx', 4).attr('ry', 4)
|
|
238
|
+
.attr('fill', 'rgba(33,33,33,0.9)')
|
|
239
|
+
.attr('stroke', '#e53935').attr('stroke-width', 1);
|
|
240
|
+
const tipText1 = tooltip.append('text')
|
|
241
|
+
.attr('fill', '#fff').attr('font-size', '11px').attr('font-weight', '600');
|
|
242
|
+
const tipText2 = tooltip.append('text')
|
|
243
|
+
.attr('fill', '#ccc').attr('font-size', '10px');
|
|
244
|
+
const tipContent1 = `현재 프로젝트`;
|
|
245
|
+
const tipContent2 = `측정값: ${this.value.toFixed(4)}${this.unit} | 성과 점수: ${currentScore.toFixed(4)}`;
|
|
246
|
+
tipText1.text(tipContent1);
|
|
247
|
+
tipText2.text(tipContent2);
|
|
248
|
+
const tipW = Math.max(tipContent1.length, tipContent2.length) * 6.5 + 20;
|
|
249
|
+
const tipH = 40;
|
|
250
|
+
const tipX = Math.min(cx + margin.left + 12, w - tipW - 5);
|
|
251
|
+
const tipY = Math.max(cy + margin.top - tipH - 10, 5);
|
|
252
|
+
tipRect.attr('x', tipX).attr('y', tipY).attr('width', tipW).attr('height', tipH);
|
|
253
|
+
tipText1.attr('x', tipX + 8).attr('y', tipY + 15);
|
|
254
|
+
tipText2.attr('x', tipX + 8).attr('y', tipY + 30);
|
|
255
|
+
g.append('circle')
|
|
256
|
+
.attr('cx', cx).attr('cy', cy)
|
|
257
|
+
.attr('r', 7)
|
|
258
|
+
.attr('fill', '#e53935').attr('stroke', '#fff').attr('stroke-width', 2)
|
|
259
|
+
.style('cursor', 'pointer')
|
|
260
|
+
.on('mouseenter', () => tooltip.attr('visibility', 'visible'))
|
|
261
|
+
.on('mouseleave', () => tooltip.attr('visibility', 'hidden'));
|
|
262
|
+
// 헤더: 현재 값 + 스코어
|
|
129
263
|
svg
|
|
130
264
|
.append('text')
|
|
131
|
-
.attr('
|
|
132
|
-
.attr('
|
|
133
|
-
.attr('y', headerY)
|
|
265
|
+
.attr('x', margin.left)
|
|
266
|
+
.attr('y', 18)
|
|
134
267
|
.attr('font-size', '12px')
|
|
135
268
|
.attr('font-weight', 'bold')
|
|
136
|
-
.attr('
|
|
137
|
-
.text(
|
|
138
|
-
}
|
|
139
|
-
else {
|
|
269
|
+
.attr('fill', '#e53935')
|
|
270
|
+
.text(`측정값: ${this.value.toFixed(3)}${this.unit}`);
|
|
140
271
|
svg
|
|
141
272
|
.append('text')
|
|
142
|
-
.attr('
|
|
143
|
-
.attr('
|
|
144
|
-
.attr('y', headerY)
|
|
273
|
+
.attr('x', margin.left + plotW / 2)
|
|
274
|
+
.attr('y', 18)
|
|
145
275
|
.attr('font-size', '12px')
|
|
146
276
|
.attr('font-weight', 'bold')
|
|
147
|
-
.attr('
|
|
148
|
-
.
|
|
149
|
-
.text(`KPI Score: 범위를 찾을 수 없음`);
|
|
277
|
+
.attr('fill', '#e53935')
|
|
278
|
+
.text(`성과 점수: ${currentScore.toFixed(4)}`);
|
|
150
279
|
}
|
|
151
280
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
281
|
+
// 제목은 차트 외부에서 표시하므로 차트 내부에는 미표시
|
|
282
|
+
}
|
|
283
|
+
/** 1~5 평가형 KPI: 5단계 세그먼트 바 */
|
|
284
|
+
renderAssessmentGauge(svg) {
|
|
285
|
+
const w = this.chartWidth || 400;
|
|
286
|
+
const h = this.chartHeight || 200;
|
|
287
|
+
const currentScore = this.value !== null && this.value !== undefined ? Math.round(this.value) : 0;
|
|
288
|
+
// 제목은 차트 외부에서 표시하므로 차트 내부에는 미표시
|
|
289
|
+
const barY = h * 0.4;
|
|
290
|
+
const barH = 36;
|
|
291
|
+
const barMargin = 40;
|
|
292
|
+
const barW = w - barMargin * 2;
|
|
293
|
+
const segW = barW / 5;
|
|
294
|
+
const colors = ['#ef5350', '#ff9800', '#ffca28', '#66bb6a', '#2e7d32'];
|
|
295
|
+
const labels = ['1', '2', '3', '4', '5'];
|
|
296
|
+
// 5개 세그먼트
|
|
297
|
+
for (let i = 0; i < 5; i++) {
|
|
298
|
+
const x = barMargin + i * segW;
|
|
299
|
+
const isActive = currentScore === i + 1;
|
|
300
|
+
// 배경
|
|
301
|
+
svg.append('rect')
|
|
302
|
+
.attr('x', x + 2).attr('y', barY)
|
|
303
|
+
.attr('width', segW - 4).attr('height', barH)
|
|
304
|
+
.attr('rx', 6)
|
|
305
|
+
.attr('fill', isActive ? colors[i] : '#e0e0e0')
|
|
306
|
+
.attr('opacity', isActive ? 1 : 0.4);
|
|
307
|
+
// 숫자 라벨
|
|
308
|
+
svg.append('text')
|
|
309
|
+
.attr('x', x + segW / 2).attr('y', barY + barH / 2 + 5)
|
|
310
|
+
.attr('text-anchor', 'middle')
|
|
311
|
+
.attr('font-size', isActive ? '16px' : '13px')
|
|
312
|
+
.attr('font-weight', isActive ? 'bold' : 'normal')
|
|
313
|
+
.attr('fill', isActive ? '#fff' : '#999')
|
|
314
|
+
.text(labels[i]);
|
|
315
|
+
// 설명 라벨 (아래)
|
|
316
|
+
const descLabels = ['매우 미흡', '미흡', '보통', '양호', '우수'];
|
|
317
|
+
svg.append('text')
|
|
318
|
+
.attr('x', x + segW / 2).attr('y', barY + barH + 18)
|
|
319
|
+
.attr('text-anchor', 'middle')
|
|
320
|
+
.attr('font-size', '10px')
|
|
321
|
+
.attr('fill', isActive ? colors[i] : '#bbb')
|
|
322
|
+
.text(descLabels[i]);
|
|
323
|
+
}
|
|
324
|
+
// 현재 점수 강조
|
|
325
|
+
if (currentScore >= 1 && currentScore <= 5) {
|
|
326
|
+
const activeX = barMargin + (currentScore - 1) * segW;
|
|
327
|
+
// 위쪽 화살표
|
|
328
|
+
svg.append('text')
|
|
329
|
+
.attr('x', activeX + segW / 2).attr('y', barY - 8)
|
|
161
330
|
.attr('text-anchor', 'middle')
|
|
162
|
-
.attr('
|
|
163
|
-
.
|
|
331
|
+
.attr('font-size', '14px').attr('font-weight', 'bold')
|
|
332
|
+
.attr('fill', colors[currentScore - 1])
|
|
333
|
+
.text(`${currentScore}점`);
|
|
334
|
+
}
|
|
335
|
+
else if (this.value !== null) {
|
|
336
|
+
svg.append('text')
|
|
337
|
+
.attr('x', w / 2).attr('y', barY - 8)
|
|
338
|
+
.attr('text-anchor', 'middle')
|
|
339
|
+
.attr('font-size', '13px').attr('fill', '#e53935')
|
|
340
|
+
.text(`Value: ${this.value}`);
|
|
164
341
|
}
|
|
165
342
|
}
|
|
166
|
-
|
|
343
|
+
/** grades에서 중간값(x) → score 포인트 배열 생성 */
|
|
344
|
+
buildMidpoints(grades) {
|
|
167
345
|
var _a;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
346
|
+
const points = [];
|
|
347
|
+
for (let i = 0; i < grades.length; i++) {
|
|
348
|
+
const g = grades[i];
|
|
349
|
+
const score = (_a = g.score) !== null && _a !== void 0 ? _a : 0;
|
|
350
|
+
const isLast = g.maxValue >= 99999;
|
|
351
|
+
if (g.minValue === g.maxValue) {
|
|
352
|
+
const prevMax = i > 0 ? grades[i - 1].maxValue : 0;
|
|
353
|
+
points.push({ x: (prevMax + g.maxValue) / 2, score, rangeMin: prevMax, rangeMax: g.maxValue, isLast: false });
|
|
354
|
+
}
|
|
355
|
+
else if (isLast) {
|
|
356
|
+
points.push({ x: g.minValue, score, rangeMin: g.minValue, rangeMax: g.minValue, isLast: true });
|
|
177
357
|
}
|
|
178
358
|
else {
|
|
179
|
-
|
|
180
|
-
min = grade.minValue;
|
|
181
|
-
max = grade.maxValue;
|
|
359
|
+
points.push({ x: (g.minValue + g.maxValue) / 2, score, rangeMin: g.minValue, rangeMax: g.maxValue, isLast: false });
|
|
182
360
|
}
|
|
183
|
-
return Object.assign(Object.assign({}, grade), { actualMin: min, actualMax: max });
|
|
184
|
-
});
|
|
185
|
-
console.log('drawVerticalChart - Grade ranges:', gradeRanges);
|
|
186
|
-
// X축 스케일 (value 범위)
|
|
187
|
-
// 마지막 grade를 제외한 모든 값으로 범위 계산 (마지막 grade는 "이상"으로 표시)
|
|
188
|
-
const allValues = gradeRanges
|
|
189
|
-
.slice(0, -1) // 마지막 제외
|
|
190
|
-
.flatMap(g => [g.actualMin, g.actualMax]);
|
|
191
|
-
// 마지막 grade의 min도 포함
|
|
192
|
-
if (gradeRanges.length > 0) {
|
|
193
|
-
allValues.push(gradeRanges[gradeRanges.length - 1].actualMin);
|
|
194
361
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
// 각 grade별로 영역과 라인 그리기
|
|
214
|
-
const isLastGrade = (idx) => idx === gradeRanges.length - 1;
|
|
215
|
-
gradeRanges.forEach((grade, index) => {
|
|
216
|
-
var _a, _b, _c;
|
|
217
|
-
// 마지막 grade는 "infinity"로 간주하고 차트 오른쪽까지 표시
|
|
218
|
-
const minValue = grade.actualMin;
|
|
219
|
-
const maxValue = grade.actualMax;
|
|
220
|
-
const effectiveMaxValue = isLastGrade(index) ? displayMax : maxValue;
|
|
221
|
-
const xMin = xScale(minValue);
|
|
222
|
-
const xMax = xScale(effectiveMaxValue);
|
|
223
|
-
// Y축 위치와 높이를 score 값에 비례하여 계산
|
|
224
|
-
const currentScore = (_a = grade.score) !== null && _a !== void 0 ? _a : 0;
|
|
225
|
-
const nextScore = index < gradeRanges.length - 1 ? ((_b = gradeRanges[index + 1].score) !== null && _b !== void 0 ? _b : currentScore) : maxScore;
|
|
226
|
-
const prevScore = index > 0 ? ((_c = gradeRanges[index - 1].score) !== null && _c !== void 0 ? _c : currentScore) : minScore;
|
|
227
|
-
const yBottom = index === 0 ? yScale(minScore) : yScale((prevScore + currentScore) / 2);
|
|
228
|
-
const yTop = isLastGrade(index) ? yScale(maxScore) : yScale((currentScore + nextScore) / 2);
|
|
229
|
-
const barHeight = yBottom - yTop;
|
|
230
|
-
const yCenter = (yTop + yBottom) / 2;
|
|
231
|
-
// 배경 영역 (매우 연한 색상)
|
|
232
|
-
g.append('rect')
|
|
233
|
-
.attr('x', xMin)
|
|
234
|
-
.attr('y', yTop)
|
|
235
|
-
.attr('width', xMax - xMin)
|
|
236
|
-
.attr('height', barHeight)
|
|
237
|
-
.attr('fill', grade.color || this.getDefaultColor(index))
|
|
238
|
-
.attr('opacity', 0.1);
|
|
239
|
-
// 수직선으로 grade 경계 표시
|
|
240
|
-
// 하한선 (마지막 grade가 아닌 경우만 - 마지막은 차트 오른쪽 경계이므로 불필요)
|
|
241
|
-
if (!isLastGrade(index)) {
|
|
242
|
-
g.append('line')
|
|
243
|
-
.attr('x1', xMin)
|
|
244
|
-
.attr('x2', xMin)
|
|
245
|
-
.attr('y1', yTop)
|
|
246
|
-
.attr('y2', yBottom)
|
|
247
|
-
.attr('stroke', grade.color || this.getDefaultColor(index))
|
|
248
|
-
.attr('stroke-width', 2);
|
|
249
|
-
}
|
|
250
|
-
// 상한선 (첫 번째와 마지막 grade 제외 - 첫 번째는 차트 왼쪽 경계, 마지막은 infinity)
|
|
251
|
-
if (index !== 0 && !isLastGrade(index)) {
|
|
252
|
-
g.append('line')
|
|
253
|
-
.attr('x1', xMax)
|
|
254
|
-
.attr('x2', xMax)
|
|
255
|
-
.attr('y1', yTop)
|
|
256
|
-
.attr('y2', yBottom)
|
|
257
|
-
.attr('stroke', grade.color || this.getDefaultColor(index))
|
|
258
|
-
.attr('stroke-width', 2)
|
|
259
|
-
.attr('stroke-dasharray', '3,3');
|
|
260
|
-
}
|
|
261
|
-
// Grade가 많은 경우: 라벨을 표시하지 않음
|
|
262
|
-
if (!isManyGrades) {
|
|
263
|
-
// Grade 라벨
|
|
264
|
-
g.append('text')
|
|
265
|
-
.attr('class', 'grade-label')
|
|
266
|
-
.attr('x', (xMin + xMax) / 2)
|
|
267
|
-
.attr('y', yCenter + 4)
|
|
268
|
-
.attr('text-anchor', 'middle')
|
|
269
|
-
.text(grade.name);
|
|
270
|
-
// Score 라벨 (score가 있을 경우만 표시)
|
|
271
|
-
if (grade.score !== undefined && grade.score !== null) {
|
|
272
|
-
g.append('text')
|
|
273
|
-
.attr('class', 'grade-label')
|
|
274
|
-
.attr('x', -5)
|
|
275
|
-
.attr('y', yCenter)
|
|
276
|
-
.attr('text-anchor', 'end')
|
|
277
|
-
.text(`Score: ${grade.score.toFixed(2)}`);
|
|
278
|
-
}
|
|
279
|
-
// Value range 라벨 (마지막 grade인 경우 "이상" 표시)
|
|
280
|
-
const rangeText = isLastGrade(index)
|
|
281
|
-
? `${minValue.toFixed(1)}${this.unit} 이상`
|
|
282
|
-
: `${minValue.toFixed(1)}-${maxValue.toFixed(1)}${this.unit}`;
|
|
283
|
-
g.append('text')
|
|
284
|
-
.attr('class', 'axis-label')
|
|
285
|
-
.attr('x', (xMin + xMax) / 2)
|
|
286
|
-
.attr('y', height + 35)
|
|
287
|
-
.attr('text-anchor', 'middle')
|
|
288
|
-
.text(rangeText);
|
|
289
|
-
}
|
|
290
|
-
});
|
|
291
|
-
// Y축 그리기
|
|
292
|
-
const yAxis = d3.axisLeft(yScale).ticks(5);
|
|
293
|
-
g.append('g').call(yAxis);
|
|
294
|
-
// Y축 라벨 (KPI Score)
|
|
295
|
-
g.append('text')
|
|
296
|
-
.attr('class', 'axis-label')
|
|
297
|
-
.attr('transform', 'rotate(-90)')
|
|
298
|
-
.attr('x', -height / 2)
|
|
299
|
-
.attr('y', -45)
|
|
300
|
-
.attr('text-anchor', 'middle')
|
|
301
|
-
.attr('font-weight', 'bold')
|
|
302
|
-
.text('KPI Score');
|
|
303
|
-
// X축 그리기 - d3 axis 사용
|
|
304
|
-
const xAxis = d3.axisBottom(xScale).ticks(10);
|
|
305
|
-
g.append('g')
|
|
306
|
-
.attr('transform', `translate(0,${height})`)
|
|
307
|
-
.call(xAxis);
|
|
308
|
-
// X축 라벨 (KPI Value)
|
|
309
|
-
g.append('text')
|
|
310
|
-
.attr('class', 'axis-label')
|
|
311
|
-
.attr('x', width / 2)
|
|
312
|
-
.attr('y', height + 40)
|
|
313
|
-
.attr('text-anchor', 'middle')
|
|
314
|
-
.text('KPI Value');
|
|
315
|
-
// 현재 value가 있을 때만 차트에 포인터 표시
|
|
316
|
-
if (this.value !== null && this.value !== undefined) {
|
|
317
|
-
// 해당 value가 속한 grade 찾기 (actualMin, actualMax 사용)
|
|
318
|
-
// 마지막 grade는 "이상"이므로 actualMin 이상만 체크
|
|
319
|
-
let matchedGradeRange = gradeRanges.find((g, idx) => {
|
|
320
|
-
if (isLastGrade(idx)) {
|
|
321
|
-
return this.value >= g.actualMin;
|
|
322
|
-
}
|
|
323
|
-
return this.value >= g.actualMin && this.value <= g.actualMax;
|
|
324
|
-
});
|
|
325
|
-
// 범위를 찾은 경우에만 차트에 포인터 표시
|
|
326
|
-
if (matchedGradeRange) {
|
|
327
|
-
const valueX = xScale(this.value);
|
|
328
|
-
const matchedScore = (_a = matchedGradeRange.score) !== null && _a !== void 0 ? _a : 0;
|
|
329
|
-
const valueY = yScale(matchedScore);
|
|
330
|
-
console.log('Current value:', this.value, 'valueX:', valueX);
|
|
331
|
-
console.log('Matched score:', matchedScore, 'valueY:', valueY);
|
|
332
|
-
// 현재 값 포인터
|
|
333
|
-
g.append('circle')
|
|
334
|
-
.attr('cx', valueX)
|
|
335
|
-
.attr('cy', valueY)
|
|
336
|
-
.attr('r', 6)
|
|
337
|
-
.attr('fill', '#e53935')
|
|
338
|
-
.attr('stroke', '#fff')
|
|
339
|
-
.attr('stroke-width', 2);
|
|
362
|
+
return points;
|
|
363
|
+
}
|
|
364
|
+
/** 포인트 배열에서 value에 대한 score를 선형 보간 */
|
|
365
|
+
interpolateScore(points, value) {
|
|
366
|
+
if (points.length === 0)
|
|
367
|
+
return null;
|
|
368
|
+
if (points.length === 1)
|
|
369
|
+
return points[0].score;
|
|
370
|
+
// value가 범위 밖인 경우
|
|
371
|
+
if (value <= points[0].x)
|
|
372
|
+
return points[0].score;
|
|
373
|
+
if (value >= points[points.length - 1].x)
|
|
374
|
+
return points[points.length - 1].score;
|
|
375
|
+
// 두 포인트 사이에서 보간
|
|
376
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
377
|
+
if (value >= points[i].x && value <= points[i + 1].x) {
|
|
378
|
+
const t = (value - points[i].x) / (points[i + 1].x - points[i].x);
|
|
379
|
+
return points[i].score + t * (points[i + 1].score - points[i].score);
|
|
340
380
|
}
|
|
341
381
|
}
|
|
342
|
-
|
|
343
|
-
drawEmptyState(svg) {
|
|
344
|
-
svg
|
|
345
|
-
.append('text')
|
|
346
|
-
.attr('x', this.chartWidth / 2)
|
|
347
|
-
.attr('y', this.chartHeight / 2)
|
|
348
|
-
.attr('text-anchor', 'middle')
|
|
349
|
-
.attr('fill', '#999')
|
|
350
|
-
.attr('font-size', '14px')
|
|
351
|
-
.text('No grade data available');
|
|
352
|
-
}
|
|
353
|
-
getDefaultColor(index) {
|
|
354
|
-
const colors = ['#4caf50', '#8bc34a', '#cddc39', '#ffeb3b', '#ffc107', '#ff9800', '#ff5722'];
|
|
355
|
-
return colors[index % colors.length];
|
|
382
|
+
return points[points.length - 1].score;
|
|
356
383
|
}
|
|
357
384
|
};
|
|
358
385
|
KpiLookupChart.styles = css `
|
|
@@ -367,24 +394,6 @@ KpiLookupChart.styles = css `
|
|
|
367
394
|
height: 100%;
|
|
368
395
|
display: block;
|
|
369
396
|
}
|
|
370
|
-
.chart-title {
|
|
371
|
-
font-size: 14px;
|
|
372
|
-
font-weight: 600;
|
|
373
|
-
fill: #333;
|
|
374
|
-
}
|
|
375
|
-
.grade-label {
|
|
376
|
-
font-size: 11px;
|
|
377
|
-
fill: #666;
|
|
378
|
-
}
|
|
379
|
-
.value-label {
|
|
380
|
-
font-size: 12px;
|
|
381
|
-
font-weight: 600;
|
|
382
|
-
fill: #e53935;
|
|
383
|
-
}
|
|
384
|
-
.axis-label {
|
|
385
|
-
font-size: 11px;
|
|
386
|
-
fill: #999;
|
|
387
|
-
}
|
|
388
397
|
`;
|
|
389
398
|
__decorate([
|
|
390
399
|
property({ type: Array }),
|
|
@@ -402,6 +411,18 @@ __decorate([
|
|
|
402
411
|
property({ type: String }),
|
|
403
412
|
__metadata("design:type", String)
|
|
404
413
|
], KpiLookupChart.prototype, "kpiName", void 0);
|
|
414
|
+
__decorate([
|
|
415
|
+
property({ type: Boolean }),
|
|
416
|
+
__metadata("design:type", Boolean)
|
|
417
|
+
], KpiLookupChart.prototype, "showDots", void 0);
|
|
418
|
+
__decorate([
|
|
419
|
+
property({ type: String }),
|
|
420
|
+
__metadata("design:type", String)
|
|
421
|
+
], KpiLookupChart.prototype, "scoreType", void 0);
|
|
422
|
+
__decorate([
|
|
423
|
+
property({ type: String }),
|
|
424
|
+
__metadata("design:type", String)
|
|
425
|
+
], KpiLookupChart.prototype, "valueType", void 0);
|
|
405
426
|
KpiLookupChart = __decorate([
|
|
406
427
|
customElement('kpi-lookup-chart')
|
|
407
428
|
], KpiLookupChart);
|