@dssp/dkpi 1.0.0-alpha.66 → 1.0.0-alpha.68
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 +150 -362
- package/dist-client/components/kpi-2d-lookup-chart.js.map +1 -1
- package/dist-client/components/kpi-boxplot-chart.js +51 -20
- package/dist-client/components/kpi-boxplot-chart.js.map +1 -1
- package/dist-client/components/kpi-lookup-chart.d.ts +15 -4
- package/dist-client/components/kpi-lookup-chart.js +248 -292
- 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/google-map/common-google-map.js +10 -8
- package/dist-client/google-map/common-google-map.js.map +1 -1
- package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js +7 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js.map +1 -1
- package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.d.ts +2 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.js +12 -4
- package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.js.map +1 -1
- package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.d.ts +2 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js +84 -25
- package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js.map +1 -1
- package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.d.ts +12 -0
- package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js +243 -16
- package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js.map +1 -1
- package/dist-client/pages/sv-project-detail.d.ts +1 -1
- package/dist-client/pages/sv-project-detail.js +25 -7
- package/dist-client/pages/sv-project-detail.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/kpi-stat/kpi-stat-query.d.ts +2 -0
- package/dist-server/service/kpi-stat/kpi-stat-query.js +120 -0
- package/dist-server/service/kpi-stat/kpi-stat-query.js.map +1 -1
- package/dist-server/service/kpi-stat/kpi-stat-types.d.ts +1 -0
- package/dist-server/service/kpi-stat/kpi-stat-types.js +4 -0
- package/dist-server/service/kpi-stat/kpi-stat-types.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/schema.graphql +7 -0
|
@@ -43,316 +43,290 @@ let KpiLookupChart = class KpiLookupChart extends LitElement {
|
|
|
43
43
|
updated() {
|
|
44
44
|
this.renderChart();
|
|
45
45
|
}
|
|
46
|
+
/** 1~5 평가형 KPI인지 판별 (grades 없거나, value가 1~5 정수) */
|
|
47
|
+
isAssessmentKpi() {
|
|
48
|
+
if (!this.grades || !Array.isArray(this.grades) || this.grades.length === 0)
|
|
49
|
+
return true;
|
|
50
|
+
const scores = this.grades.map(g => g.score).filter(s => s !== undefined && s !== null);
|
|
51
|
+
return scores.length <= 5 && scores.every(s => s >= 1 && s <= 5 && s === Math.floor(s));
|
|
52
|
+
}
|
|
46
53
|
renderChart() {
|
|
47
54
|
const svg = d3.select(this.renderRoot.querySelector('#lookup-chart'));
|
|
48
55
|
svg.selectAll('*').remove();
|
|
49
|
-
//
|
|
50
|
-
if (
|
|
51
|
-
this.
|
|
56
|
+
// 1~5 평가형 KPI → 세그먼트 바
|
|
57
|
+
if (this.isAssessmentKpi()) {
|
|
58
|
+
this.renderAssessmentGauge(svg);
|
|
52
59
|
return;
|
|
53
60
|
}
|
|
54
61
|
const w = this.chartWidth || 400;
|
|
55
62
|
const h = this.chartHeight || 200;
|
|
56
|
-
const margin = { top:
|
|
63
|
+
const margin = { top: 50, right: 30, bottom: 45, left: 55 };
|
|
57
64
|
const plotW = w - margin.left - margin.right;
|
|
58
65
|
const plotH = h - margin.top - margin.bottom;
|
|
66
|
+
if (plotW <= 0 || plotH <= 0)
|
|
67
|
+
return;
|
|
59
68
|
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
69
|
const sortedGrades = [...this.grades].sort((a, b) => a.minValue - b.minValue);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
//
|
|
70
|
+
// 중간값 포인트 생성 (maxValue >= 99999인 마지막 구간 제외)
|
|
71
|
+
const points = this.buildMidpoints(sortedGrades);
|
|
72
|
+
if (points.length === 0)
|
|
73
|
+
return;
|
|
74
|
+
// 스케일 — grade의 minValue~maxValue 범위, 마지막 grade의 큰 maxValue(999, 99999 등) 제외
|
|
75
|
+
const allMaxValues = sortedGrades.map(g => g.maxValue).sort((a, b) => a - b);
|
|
76
|
+
const secondLargestMax = allMaxValues.length > 1 ? allMaxValues[allMaxValues.length - 2] : allMaxValues[0];
|
|
77
|
+
const lastGradeMin = sortedGrades[sortedGrades.length - 1].minValue;
|
|
78
|
+
let xMin = sortedGrades[0].minValue;
|
|
79
|
+
let xMax = Math.max(secondLargestMax, lastGradeMin);
|
|
79
80
|
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}`);
|
|
88
|
-
}
|
|
89
|
-
else {
|
|
90
|
-
svg
|
|
91
|
-
.append('text')
|
|
92
|
-
.attr('class', 'value-label')
|
|
93
|
-
.attr('x', 10)
|
|
94
|
-
.attr('y', headerY)
|
|
95
|
-
.attr('font-size', '12px')
|
|
96
|
-
.attr('font-weight', 'bold')
|
|
97
|
-
.attr('fill', '#999')
|
|
98
|
-
.text(`KPI Value: N/A`);
|
|
81
|
+
xMax = Math.max(xMax, this.value);
|
|
99
82
|
}
|
|
100
|
-
|
|
83
|
+
const xPad = (xMax - xMin) * 0.15 || 0.5;
|
|
84
|
+
const xScale = d3.scaleLinear().domain([xMin - xPad, xMax + xPad]).range([0, plotW]).nice();
|
|
85
|
+
const yExtent = d3.extent(points, d => d.score);
|
|
86
|
+
const [yMin, yMax] = yExtent;
|
|
87
|
+
const yPad = (yMax - yMin) * 0.08 || 0.1;
|
|
88
|
+
const yScale = d3.scaleLinear().domain([yMin - yPad, yMax + yPad]).range([plotH, 0]).nice();
|
|
89
|
+
// 스코어 구간 배경 (연한 색상)
|
|
90
|
+
const scoreColors = ['#e8f5e9', '#f1f8e9', '#fff9c4', '#fff3e0', '#fce4ec'];
|
|
91
|
+
const uniqueScores = [...new Set(points.map(p => p.score))].sort((a, b) => b - a);
|
|
92
|
+
uniqueScores.forEach((score, i) => {
|
|
93
|
+
const yPos = yScale(score);
|
|
94
|
+
const nextScore = i < uniqueScores.length - 1 ? uniqueScores[i + 1] : yMin - yPad;
|
|
95
|
+
const yNext = yScale(nextScore);
|
|
96
|
+
g.append('rect')
|
|
97
|
+
.attr('x', 0)
|
|
98
|
+
.attr('y', Math.min(yPos, yNext))
|
|
99
|
+
.attr('width', plotW)
|
|
100
|
+
.attr('height', Math.abs(yNext - yPos))
|
|
101
|
+
.attr('fill', scoreColors[i % scoreColors.length])
|
|
102
|
+
.attr('opacity', 0.5);
|
|
103
|
+
});
|
|
104
|
+
// X축 범위 내 포인트만 사용
|
|
105
|
+
const xDomainMax = xMax + xPad;
|
|
106
|
+
const visiblePoints = points.filter(p => p.x <= xDomainMax);
|
|
107
|
+
// 스코어 커브 (중간값 연결)
|
|
108
|
+
const line = d3
|
|
109
|
+
.line()
|
|
110
|
+
.x(d => xScale(d.x))
|
|
111
|
+
.y(d => yScale(d.score))
|
|
112
|
+
.curve(d3.curveMonotoneX);
|
|
113
|
+
g.append('path')
|
|
114
|
+
.datum(visiblePoints)
|
|
115
|
+
.attr('d', line)
|
|
116
|
+
.attr('fill', 'none')
|
|
117
|
+
.attr('stroke', '#1976d2')
|
|
118
|
+
.attr('stroke-width', 2.5);
|
|
119
|
+
// 축
|
|
120
|
+
g.append('g')
|
|
121
|
+
.attr('transform', `translate(0,${plotH})`)
|
|
122
|
+
.call(d3.axisBottom(xScale).ticks(Math.min(points.length, 8)))
|
|
123
|
+
.selectAll('text')
|
|
124
|
+
.attr('font-size', '10px');
|
|
125
|
+
g.append('g').call(d3.axisLeft(yScale).ticks(5)).selectAll('text').attr('font-size', '10px');
|
|
126
|
+
// 축 라벨
|
|
127
|
+
g.append('text')
|
|
128
|
+
.attr('x', plotW / 2)
|
|
129
|
+
.attr('y', plotH + 35)
|
|
130
|
+
.attr('text-anchor', 'middle')
|
|
131
|
+
.attr('font-size', '11px')
|
|
132
|
+
.attr('fill', '#666')
|
|
133
|
+
.text(`측정값${this.unit ? ` (${this.unit})` : ''}`);
|
|
134
|
+
g.append('text')
|
|
135
|
+
.attr('transform', 'rotate(-90)')
|
|
136
|
+
.attr('x', -plotH / 2)
|
|
137
|
+
.attr('y', -40)
|
|
138
|
+
.attr('text-anchor', 'middle')
|
|
139
|
+
.attr('font-size', '11px')
|
|
140
|
+
.attr('fill', '#666')
|
|
141
|
+
.text('성과 점수');
|
|
142
|
+
// 현재 값 표시
|
|
101
143
|
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
|
-
|
|
144
|
+
const currentScore = this.interpolateScore(points, this.value);
|
|
145
|
+
if (currentScore !== null) {
|
|
146
|
+
const cx = xScale(this.value);
|
|
147
|
+
const cy = yScale(currentScore);
|
|
148
|
+
// 가이드 라인
|
|
149
|
+
g.append('line')
|
|
150
|
+
.attr('x1', cx).attr('x2', cx)
|
|
151
|
+
.attr('y1', cy).attr('y2', plotH)
|
|
152
|
+
.attr('stroke', '#e53935').attr('stroke-width', 1).attr('stroke-dasharray', '3,3');
|
|
153
|
+
g.append('line')
|
|
154
|
+
.attr('x1', 0).attr('x2', cx)
|
|
155
|
+
.attr('y1', cy).attr('y2', cy)
|
|
156
|
+
.attr('stroke', '#e53935').attr('stroke-width', 1).attr('stroke-dasharray', '3,3');
|
|
157
|
+
// 현재 값 포인트 + 툴팁
|
|
158
|
+
const tooltip = svg.append('g')
|
|
159
|
+
.attr('class', 'tooltip-group')
|
|
160
|
+
.attr('visibility', 'hidden');
|
|
161
|
+
const tipRect = tooltip.append('rect')
|
|
162
|
+
.attr('rx', 4).attr('ry', 4)
|
|
163
|
+
.attr('fill', 'rgba(33,33,33,0.9)')
|
|
164
|
+
.attr('stroke', '#e53935').attr('stroke-width', 1);
|
|
165
|
+
const tipText1 = tooltip.append('text')
|
|
166
|
+
.attr('fill', '#fff').attr('font-size', '11px').attr('font-weight', '600');
|
|
167
|
+
const tipText2 = tooltip.append('text')
|
|
168
|
+
.attr('fill', '#ccc').attr('font-size', '10px');
|
|
169
|
+
const tipContent1 = `${this.kpiName || 'KPI'}`;
|
|
170
|
+
const tipContent2 = `측정값: ${this.value.toFixed(4)}${this.unit} | 성과 점수: ${currentScore.toFixed(4)}`;
|
|
171
|
+
tipText1.text(tipContent1);
|
|
172
|
+
tipText2.text(tipContent2);
|
|
173
|
+
const tipW = Math.max(tipContent1.length, tipContent2.length) * 6.5 + 20;
|
|
174
|
+
const tipH = 40;
|
|
175
|
+
const tipX = Math.min(cx + margin.left + 12, w - tipW - 5);
|
|
176
|
+
const tipY = Math.max(cy + margin.top - tipH - 10, 5);
|
|
177
|
+
tipRect.attr('x', tipX).attr('y', tipY).attr('width', tipW).attr('height', tipH);
|
|
178
|
+
tipText1.attr('x', tipX + 8).attr('y', tipY + 15);
|
|
179
|
+
tipText2.attr('x', tipX + 8).attr('y', tipY + 30);
|
|
180
|
+
g.append('circle')
|
|
181
|
+
.attr('cx', cx).attr('cy', cy)
|
|
182
|
+
.attr('r', 7)
|
|
183
|
+
.attr('fill', '#e53935').attr('stroke', '#fff').attr('stroke-width', 2)
|
|
184
|
+
.style('cursor', 'pointer')
|
|
185
|
+
.on('mouseenter', () => tooltip.attr('visibility', 'visible'))
|
|
186
|
+
.on('mouseleave', () => tooltip.attr('visibility', 'hidden'));
|
|
187
|
+
// 헤더: 현재 값 + 스코어
|
|
129
188
|
svg
|
|
130
189
|
.append('text')
|
|
131
|
-
.attr('
|
|
132
|
-
.attr('
|
|
133
|
-
.attr('y', headerY)
|
|
190
|
+
.attr('x', margin.left)
|
|
191
|
+
.attr('y', 18)
|
|
134
192
|
.attr('font-size', '12px')
|
|
135
193
|
.attr('font-weight', 'bold')
|
|
136
|
-
.attr('
|
|
137
|
-
.text(
|
|
138
|
-
}
|
|
139
|
-
else {
|
|
194
|
+
.attr('fill', '#e53935')
|
|
195
|
+
.text(`측정값: ${this.value.toFixed(3)}${this.unit}`);
|
|
140
196
|
svg
|
|
141
197
|
.append('text')
|
|
142
|
-
.attr('
|
|
143
|
-
.attr('
|
|
144
|
-
.attr('y', headerY)
|
|
198
|
+
.attr('x', margin.left + plotW / 2)
|
|
199
|
+
.attr('y', 18)
|
|
145
200
|
.attr('font-size', '12px')
|
|
146
201
|
.attr('font-weight', 'bold')
|
|
147
|
-
.attr('
|
|
148
|
-
.
|
|
149
|
-
.text(`KPI Score: 범위를 찾을 수 없음`);
|
|
202
|
+
.attr('fill', '#e53935')
|
|
203
|
+
.text(`성과 점수: ${currentScore.toFixed(4)}`);
|
|
150
204
|
}
|
|
151
205
|
}
|
|
152
|
-
|
|
153
|
-
|
|
206
|
+
// 제목
|
|
207
|
+
if (this.kpiName) {
|
|
154
208
|
svg
|
|
155
209
|
.append('text')
|
|
156
|
-
.attr('
|
|
157
|
-
.attr('
|
|
158
|
-
.attr('y', headerY)
|
|
159
|
-
.attr('font-size', '12px')
|
|
160
|
-
.attr('font-weight', 'bold')
|
|
210
|
+
.attr('x', w / 2)
|
|
211
|
+
.attr('y', 35)
|
|
161
212
|
.attr('text-anchor', 'middle')
|
|
162
|
-
.attr('
|
|
163
|
-
.
|
|
213
|
+
.attr('font-size', '13px')
|
|
214
|
+
.attr('font-weight', '600')
|
|
215
|
+
.attr('fill', '#333')
|
|
216
|
+
.text(this.kpiName);
|
|
164
217
|
}
|
|
165
218
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
else {
|
|
179
|
-
// Range 값인 경우: 그대로 사용
|
|
180
|
-
min = grade.minValue;
|
|
181
|
-
max = grade.maxValue;
|
|
182
|
-
}
|
|
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);
|
|
219
|
+
/** 1~5 평가형 KPI: 5단계 세그먼트 바 */
|
|
220
|
+
renderAssessmentGauge(svg) {
|
|
221
|
+
const w = this.chartWidth || 400;
|
|
222
|
+
const h = this.chartHeight || 200;
|
|
223
|
+
const currentScore = this.value !== null && this.value !== undefined ? Math.round(this.value) : 0;
|
|
224
|
+
// 제목
|
|
225
|
+
if (this.kpiName) {
|
|
226
|
+
svg.append('text')
|
|
227
|
+
.attr('x', w / 2).attr('y', 28)
|
|
228
|
+
.attr('text-anchor', 'middle')
|
|
229
|
+
.attr('font-size', '13px').attr('font-weight', '600').attr('fill', '#333')
|
|
230
|
+
.text(this.kpiName);
|
|
194
231
|
}
|
|
195
|
-
const
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
232
|
+
const barY = h * 0.4;
|
|
233
|
+
const barH = 36;
|
|
234
|
+
const barMargin = 40;
|
|
235
|
+
const barW = w - barMargin * 2;
|
|
236
|
+
const segW = barW / 5;
|
|
237
|
+
const colors = ['#ef5350', '#ff9800', '#ffca28', '#66bb6a', '#2e7d32'];
|
|
238
|
+
const labels = ['1', '2', '3', '4', '5'];
|
|
239
|
+
// 5개 세그먼트
|
|
240
|
+
for (let i = 0; i < 5; i++) {
|
|
241
|
+
const x = barMargin + i * segW;
|
|
242
|
+
const isActive = currentScore === i + 1;
|
|
243
|
+
// 배경
|
|
244
|
+
svg.append('rect')
|
|
245
|
+
.attr('x', x + 2).attr('y', barY)
|
|
246
|
+
.attr('width', segW - 4).attr('height', barH)
|
|
247
|
+
.attr('rx', 6)
|
|
248
|
+
.attr('fill', isActive ? colors[i] : '#e0e0e0')
|
|
249
|
+
.attr('opacity', isActive ? 1 : 0.4);
|
|
250
|
+
// 숫자 라벨
|
|
251
|
+
svg.append('text')
|
|
252
|
+
.attr('x', x + segW / 2).attr('y', barY + barH / 2 + 5)
|
|
253
|
+
.attr('text-anchor', 'middle')
|
|
254
|
+
.attr('font-size', isActive ? '16px' : '13px')
|
|
255
|
+
.attr('font-weight', isActive ? 'bold' : 'normal')
|
|
256
|
+
.attr('fill', isActive ? '#fff' : '#999')
|
|
257
|
+
.text(labels[i]);
|
|
258
|
+
// 설명 라벨 (아래)
|
|
259
|
+
const descLabels = ['매우 미흡', '미흡', '보통', '양호', '우수'];
|
|
260
|
+
svg.append('text')
|
|
261
|
+
.attr('x', x + segW / 2).attr('y', barY + barH + 18)
|
|
262
|
+
.attr('text-anchor', 'middle')
|
|
263
|
+
.attr('font-size', '10px')
|
|
264
|
+
.attr('fill', isActive ? colors[i] : '#bbb')
|
|
265
|
+
.text(descLabels[i]);
|
|
202
266
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
g.
|
|
233
|
-
|
|
234
|
-
.
|
|
235
|
-
.
|
|
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');
|
|
267
|
+
// 현재 점수 강조
|
|
268
|
+
if (currentScore >= 1 && currentScore <= 5) {
|
|
269
|
+
const activeX = barMargin + (currentScore - 1) * segW;
|
|
270
|
+
// 위쪽 화살표
|
|
271
|
+
svg.append('text')
|
|
272
|
+
.attr('x', activeX + segW / 2).attr('y', barY - 8)
|
|
273
|
+
.attr('text-anchor', 'middle')
|
|
274
|
+
.attr('font-size', '14px').attr('font-weight', 'bold')
|
|
275
|
+
.attr('fill', colors[currentScore - 1])
|
|
276
|
+
.text(`${currentScore}점`);
|
|
277
|
+
}
|
|
278
|
+
else if (this.value !== null) {
|
|
279
|
+
svg.append('text')
|
|
280
|
+
.attr('x', w / 2).attr('y', barY - 8)
|
|
281
|
+
.attr('text-anchor', 'middle')
|
|
282
|
+
.attr('font-size', '13px').attr('fill', '#e53935')
|
|
283
|
+
.text(`Value: ${this.value}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/** grades에서 중간값(x) → score 포인트 배열 생성 */
|
|
287
|
+
buildMidpoints(grades) {
|
|
288
|
+
var _a;
|
|
289
|
+
const points = [];
|
|
290
|
+
// 마지막 유효 grade의 maxValue (99999 제외)
|
|
291
|
+
const validGrades = grades.filter(g => g.maxValue < 99999);
|
|
292
|
+
const lastValidMax = validGrades.length > 0 ? validGrades[validGrades.length - 1].maxValue : 1;
|
|
293
|
+
for (let i = 0; i < grades.length; i++) {
|
|
294
|
+
const g = grades[i];
|
|
295
|
+
const score = (_a = g.score) !== null && _a !== void 0 ? _a : 0;
|
|
296
|
+
if (g.minValue === g.maxValue) {
|
|
297
|
+
// point value
|
|
298
|
+
const prevMax = i > 0 ? grades[i - 1].maxValue : 0;
|
|
299
|
+
points.push({ x: (prevMax + g.maxValue) / 2, score });
|
|
260
300
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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);
|
|
301
|
+
else if (g.maxValue >= 99999) {
|
|
302
|
+
// 마지막 "이상" 구간 — minValue 사용 (99999는 무시)
|
|
303
|
+
points.push({ x: g.minValue, score });
|
|
289
304
|
}
|
|
290
|
-
|
|
291
|
-
|
|
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);
|
|
305
|
+
else {
|
|
306
|
+
points.push({ x: (g.minValue + g.maxValue) / 2, score });
|
|
340
307
|
}
|
|
341
308
|
}
|
|
309
|
+
return points;
|
|
342
310
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
.
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
.
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
311
|
+
/** 포인트 배열에서 value에 대한 score를 선형 보간 */
|
|
312
|
+
interpolateScore(points, value) {
|
|
313
|
+
if (points.length === 0)
|
|
314
|
+
return null;
|
|
315
|
+
if (points.length === 1)
|
|
316
|
+
return points[0].score;
|
|
317
|
+
// value가 범위 밖인 경우
|
|
318
|
+
if (value <= points[0].x)
|
|
319
|
+
return points[0].score;
|
|
320
|
+
if (value >= points[points.length - 1].x)
|
|
321
|
+
return points[points.length - 1].score;
|
|
322
|
+
// 두 포인트 사이에서 보간
|
|
323
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
324
|
+
if (value >= points[i].x && value <= points[i + 1].x) {
|
|
325
|
+
const t = (value - points[i].x) / (points[i + 1].x - points[i].x);
|
|
326
|
+
return points[i].score + t * (points[i + 1].score - points[i].score);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return points[points.length - 1].score;
|
|
356
330
|
}
|
|
357
331
|
};
|
|
358
332
|
KpiLookupChart.styles = css `
|
|
@@ -367,24 +341,6 @@ KpiLookupChart.styles = css `
|
|
|
367
341
|
height: 100%;
|
|
368
342
|
display: block;
|
|
369
343
|
}
|
|
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
344
|
`;
|
|
389
345
|
__decorate([
|
|
390
346
|
property({ type: Array }),
|