@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.
Files changed (38) hide show
  1. package/dist-client/components/kpi-2d-lookup-chart.d.ts +10 -30
  2. package/dist-client/components/kpi-2d-lookup-chart.js +150 -362
  3. package/dist-client/components/kpi-2d-lookup-chart.js.map +1 -1
  4. package/dist-client/components/kpi-boxplot-chart.js +51 -20
  5. package/dist-client/components/kpi-boxplot-chart.js.map +1 -1
  6. package/dist-client/components/kpi-lookup-chart.d.ts +15 -4
  7. package/dist-client/components/kpi-lookup-chart.js +248 -292
  8. package/dist-client/components/kpi-lookup-chart.js.map +1 -1
  9. package/dist-client/components/kpi-radar-chart.js +29 -5
  10. package/dist-client/components/kpi-radar-chart.js.map +1 -1
  11. package/dist-client/components/kpi-single-boxplot-chart.js +72 -14
  12. package/dist-client/components/kpi-single-boxplot-chart.js.map +1 -1
  13. package/dist-client/google-map/common-google-map.js +10 -8
  14. package/dist-client/google-map/common-google-map.js.map +1 -1
  15. package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js +7 -0
  16. package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js.map +1 -1
  17. package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.d.ts +2 -0
  18. package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.js +12 -4
  19. package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.js.map +1 -1
  20. package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.d.ts +2 -0
  21. package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js +84 -25
  22. package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js.map +1 -1
  23. package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.d.ts +12 -0
  24. package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js +243 -16
  25. package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js.map +1 -1
  26. package/dist-client/pages/sv-project-detail.d.ts +1 -1
  27. package/dist-client/pages/sv-project-detail.js +25 -7
  28. package/dist-client/pages/sv-project-detail.js.map +1 -1
  29. package/dist-client/tsconfig.tsbuildinfo +1 -1
  30. package/dist-server/service/kpi-stat/kpi-stat-query.d.ts +2 -0
  31. package/dist-server/service/kpi-stat/kpi-stat-query.js +120 -0
  32. package/dist-server/service/kpi-stat/kpi-stat-query.js.map +1 -1
  33. package/dist-server/service/kpi-stat/kpi-stat-types.d.ts +1 -0
  34. package/dist-server/service/kpi-stat/kpi-stat-types.js +4 -0
  35. package/dist-server/service/kpi-stat/kpi-stat-types.js.map +1 -1
  36. package/dist-server/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +2 -2
  38. 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 (!this.grades || this.grades.length === 0) {
51
- this.drawEmptyState(svg);
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: 40, right: 30, bottom: 50, left: 60 };
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
- this.drawChart(g, svg, sortedGrades, plotW, plotH, w);
75
- }
76
- drawHeader(svg, chartWidth) {
77
- const headerY = 30;
78
- // KPI Value 표시
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
- svg
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
- // KPI Score 표시
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
- // grades를 정렬
103
- const sortedGrades = [...this.grades].sort((a, b) => a.minValue - b.minValue);
104
- // grades를 범위로 변환
105
- const gradeRanges = sortedGrades.map((grade, index) => {
106
- let min, max;
107
- if (grade.minValue === grade.maxValue) {
108
- min = index === 0 ? 0 : (sortedGrades[index - 1].maxValue || 0);
109
- max = grade.maxValue;
110
- }
111
- else {
112
- min = grade.minValue;
113
- max = grade.maxValue;
114
- }
115
- return Object.assign(Object.assign({}, grade), { actualMin: min, actualMax: max });
116
- });
117
- // 마지막 grade인지 확인
118
- const isLastGrade = (idx) => idx === gradeRanges.length - 1;
119
- // 해당 value가 속한 grade 찾기
120
- let matchedGradeRange = gradeRanges.find((g, idx) => {
121
- if (isLastGrade(idx)) {
122
- return this.value >= g.actualMin;
123
- }
124
- return this.value >= g.actualMin && this.value <= g.actualMax;
125
- });
126
- const kpiScore = matchedGradeRange === null || matchedGradeRange === void 0 ? void 0 : matchedGradeRange.score;
127
- // KPI Score 표시
128
- if (kpiScore !== undefined && kpiScore !== null) {
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('class', 'value-label')
132
- .attr('x', chartWidth / 2)
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('text-anchor', 'middle')
137
- .text(`KPI Score: ${kpiScore.toFixed(2)}`);
138
- }
139
- else {
194
+ .attr('fill', '#e53935')
195
+ .text(`측정값: ${this.value.toFixed(3)}${this.unit}`);
140
196
  svg
141
197
  .append('text')
142
- .attr('class', 'value-label')
143
- .attr('x', chartWidth / 2)
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('text-anchor', 'middle')
148
- .attr('fill', '#ff0000')
149
- .text(`KPI Score: 범위를 찾을 수 없음`);
202
+ .attr('fill', '#e53935')
203
+ .text(`성과 점수: ${currentScore.toFixed(4)}`);
150
204
  }
151
205
  }
152
- else {
153
- // value가 없을 때 KPI Score N/A 표시
206
+ // 제목
207
+ if (this.kpiName) {
154
208
  svg
155
209
  .append('text')
156
- .attr('class', 'value-label')
157
- .attr('x', chartWidth / 2)
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('fill', '#999')
163
- .text(`KPI Score: N/A`);
213
+ .attr('font-size', '13px')
214
+ .attr('font-weight', '600')
215
+ .attr('fill', '#333')
216
+ .text(this.kpiName);
164
217
  }
165
218
  }
166
- drawChart(g, svg, grades, width, height, chartWidth) {
167
- var _a;
168
- console.log('drawVerticalChart - Original grades:', grades);
169
- console.log('drawVerticalChart - Current value:', this.value);
170
- // grades를 범위로 변환 (minValue == maxValue인 경우, 이전 값부터 현재 값까지를 범위로 간주)
171
- const gradeRanges = grades.map((grade, index) => {
172
- let min, max;
173
- if (grade.minValue === grade.maxValue) {
174
- // Point 값인 경우: 이전 grade의 max ~ 현재 grade의 value
175
- min = index === 0 ? 0 : (grades[index - 1].maxValue || 0);
176
- max = grade.maxValue;
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 minVal = allValues.length > 0 ? Math.min(...allValues, 0) : 0;
196
- const maxVal = allValues.length > 0 ? Math.max(...allValues) : 100;
197
- console.log('drawVerticalChart - Scaled min/max:', minVal, maxVal);
198
- // 현재 value가 있으면 범위에 포함
199
- let displayMax = maxVal;
200
- if (this.value !== null && this.value !== undefined) {
201
- displayMax = Math.max(maxVal, this.value);
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
- const xScale = d3.scaleLinear().domain([minVal, displayMax]).range([0, width]).nice();
204
- // Y축 스케일 (score 범위) - 실제 score 값에 비례하도록 설정
205
- const scores = gradeRanges.map(g => g.score).filter(s => s !== undefined && s !== null);
206
- const minScore = Math.min(...scores);
207
- const maxScore = Math.max(...scores);
208
- const yScale = d3.scaleLinear()
209
- .domain([minScore, maxScore])
210
- .range([height, 0])
211
- .nice();
212
- const isManyGrades = grades.length > 10; // 10개 이상이면 "많은 경우"로 간주
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');
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
- // 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);
301
+ else if (g.maxValue >= 99999) {
302
+ // 마지막 "이상" 구간 — minValue 사용 (99999는 무시)
303
+ points.push({ x: g.minValue, score });
289
304
  }
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);
305
+ else {
306
+ points.push({ x: (g.minValue + g.maxValue) / 2, score });
340
307
  }
341
308
  }
309
+ return points;
342
310
  }
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];
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 }),