@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.
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 +145 -362
  3. package/dist-client/components/kpi-2d-lookup-chart.js.map +1 -1
  4. package/dist-client/components/kpi-boxplot-chart.js +51 -24
  5. package/dist-client/components/kpi-boxplot-chart.js.map +1 -1
  6. package/dist-client/components/kpi-lookup-chart.d.ts +28 -4
  7. package/dist-client/components/kpi-lookup-chart.js +314 -293
  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/pages/kpi-admin/dssp-kpi-overview.d.ts +46 -0
  14. package/dist-client/pages/kpi-admin/dssp-kpi-overview.js +378 -0
  15. package/dist-client/pages/kpi-admin/dssp-kpi-overview.js.map +1 -0
  16. package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js +0 -9
  17. package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js.map +1 -1
  18. package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js +0 -1
  19. package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js.map +1 -1
  20. package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js +0 -4
  21. package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js.map +1 -1
  22. package/dist-client/pages/kpi-metric-value/kpi-metric-value-editor-page.js +0 -11
  23. package/dist-client/pages/kpi-metric-value/kpi-metric-value-editor-page.js.map +1 -1
  24. package/dist-client/pages/sv-project-completed-list.js +0 -1
  25. package/dist-client/pages/sv-project-completed-list.js.map +1 -1
  26. package/dist-client/pages/sv-project-detail.d.ts +11 -1
  27. package/dist-client/pages/sv-project-detail.js +150 -20
  28. package/dist-client/pages/sv-project-detail.js.map +1 -1
  29. package/dist-client/pages/sv-project-list.js +0 -1
  30. package/dist-client/pages/sv-project-list.js.map +1 -1
  31. package/dist-client/route.d.ts +1 -1
  32. package/dist-client/route.js +3 -0
  33. package/dist-client/route.js.map +1 -1
  34. package/dist-client/tsconfig.tsbuildinfo +1 -1
  35. package/dist-server/tsconfig.tsbuildinfo +1 -1
  36. package/package.json +2 -2
  37. package/schema.graphql +26 -5
  38. 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.grades.length === 0) {
51
- this.drawEmptyState(svg);
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: 40, right: 30, bottom: 50, left: 60 };
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
- this.drawChart(g, svg, sortedGrades, plotW, plotH, w);
75
- }
76
- drawHeader(svg, chartWidth) {
77
- const headerY = 30;
78
- // KPI Value 표시
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
- 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}`);
99
+ xMax = Math.max(xMax, this.value);
88
100
  }
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`);
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
- // KPI Score 표시
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
- // 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) {
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('class', 'value-label')
132
- .attr('x', chartWidth / 2)
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('text-anchor', 'middle')
137
- .text(`KPI Score: ${kpiScore.toFixed(2)}`);
138
- }
139
- else {
269
+ .attr('fill', '#e53935')
270
+ .text(`측정값: ${this.value.toFixed(3)}${this.unit}`);
140
271
  svg
141
272
  .append('text')
142
- .attr('class', 'value-label')
143
- .attr('x', chartWidth / 2)
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('text-anchor', 'middle')
148
- .attr('fill', '#ff0000')
149
- .text(`KPI Score: 범위를 찾을 수 없음`);
277
+ .attr('fill', '#e53935')
278
+ .text(`성과 점수: ${currentScore.toFixed(4)}`);
150
279
  }
151
280
  }
152
- else {
153
- // value가 없을 때 KPI Score N/A 표시
154
- svg
155
- .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')
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('fill', '#999')
163
- .text(`KPI Score: N/A`);
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
- drawChart(g, svg, grades, width, height, chartWidth) {
343
+ /** grades에서 중간값(x) score 포인트 배열 생성 */
344
+ buildMidpoints(grades) {
167
345
  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;
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
- // Range 값인 경우: 그대로 사용
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
- 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);
202
- }
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');
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);