@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
@@ -1,12 +1,14 @@
1
1
  import { LitElement } from 'lit';
2
2
  /**
3
- * X13 2D lookup KPI를 위한 차트 컴포넌트
3
+ * X13 성과 분포 히트맵 (2D 룩업)
4
4
  *
5
- * Y축: 성과수준 (0~1) 다른 1D 차트와 동일
6
- * X축: 편차율 (KPI value)
7
- * 실선 계단: 현재 공정률 기준 편차→성과 매핑
8
- * 반투명 밴드: 공정률 0~100% 범위에서 경계 이동 폭
9
- * 점: 현재 프로젝트 위치
5
+ * 공정률(%)×공기편차(%) 2차원 입력에 대한 성과수준(1~5점) 분포를 히트맵으로 표시.
6
+ * 현재 프로젝트의 위치를 강조 표시하여 성과 수준을 직관적으로 파악.
7
+ *
8
+ * X축: 공정률 (0~100%)
9
+ * Y축: 공기편차 (%)
10
+ * 색상: 성과수준 (5점=파랑 ~ 1점=빨강)
11
+ * ● 현재 프로젝트 위치 (강조 표시)
10
12
  */
11
13
  interface Grade2DRow {
12
14
  progressRate: number;
@@ -21,15 +23,10 @@ interface Grade2DLookup {
21
23
  rows: Grade2DRow[];
22
24
  }
23
25
  export declare class Kpi2dLookupChart extends LitElement {
24
- /** 2D grades 객체 ({ type: 'PROGRESS_DEVIATION_LOOKUP', rows: [...] }) */
25
26
  grades: Grade2DLookup | null;
26
- /** 현재 편차율 (KPI value = formula 결과) */
27
27
  value: number | null;
28
- /** 현재 공정률 (0~100) */
29
28
  progressRate: number;
30
- /** 단위 표시 */
31
29
  unit: string;
32
- /** 차트 제목 */
33
30
  kpiName: string;
34
31
  static styles: import("lit").CSSResult;
35
32
  private chartWidth;
@@ -39,25 +36,8 @@ export declare class Kpi2dLookupChart extends LitElement {
39
36
  connectedCallback(): void;
40
37
  disconnectedCallback(): void;
41
38
  updated(): void;
42
- private renderChart;
43
- /**
44
- * 현재 공정률에서의 점수 계산
45
- */
39
+ private getScore;
46
40
  private getCurrentScore;
47
- private drawHeader;
48
- private draw2DChart;
49
- /**
50
- * 반투명 밴드: 공정률 0~100% 전체에서 각 경계가 이동하는 범위
51
- */
52
- private drawBands;
53
- /**
54
- * 현재 공정률 기준 계단 함수 그리기
55
- */
56
- private drawStepFunction;
57
- /**
58
- * 현재 프로젝트 위치 표시
59
- */
60
- private drawCurrentValuePointer;
61
- private drawEmptyState;
41
+ private renderChart;
62
42
  }
63
43
  export {};
@@ -2,26 +2,27 @@ import { __decorate, __metadata } from "tslib";
2
2
  import { LitElement, html, css } from 'lit';
3
3
  import { customElement, property } from 'lit/decorators.js';
4
4
  import * as d3 from 'd3';
5
- /* 5점→1.0, 4점→0.8, 3점→0.6, 2점→0.4, 1점→0.2 */
6
- const SCORE_LEVELS = [
7
- { score: 5, normalized: 1.0, label: '5점', color: '#1565c0' },
8
- { score: 4, normalized: 0.8, label: '4점', color: '#4caf50' },
9
- { score: 3, normalized: 0.6, label: '3점', color: '#ffc107' },
10
- { score: 2, normalized: 0.4, label: '2점', color: '#ff9800' },
11
- { score: 1, normalized: 0.2, label: '1점', color: '#e53935' }
12
- ];
5
+ const SCORE_COLORS = {
6
+ 5: '#1565c0',
7
+ 4: '#4caf50',
8
+ 3: '#ffc107',
9
+ 2: '#ff9800',
10
+ 1: '#e53935'
11
+ };
12
+ const SCORE_LABELS = {
13
+ 5: '5점 (우수)',
14
+ 4: '4점 (양호)',
15
+ 3: '3점 (보통)',
16
+ 2: '2점 (미흡)',
17
+ 1: '1점 (매우 미흡)'
18
+ };
13
19
  let Kpi2dLookupChart = class Kpi2dLookupChart extends LitElement {
14
20
  constructor() {
15
21
  super(...arguments);
16
- /** 2D grades 객체 ({ type: 'PROGRESS_DEVIATION_LOOKUP', rows: [...] }) */
17
22
  this.grades = null;
18
- /** 현재 편차율 (KPI value = formula 결과) */
19
23
  this.value = null;
20
- /** 현재 공정률 (0~100) */
21
24
  this.progressRate = 50;
22
- /** 단위 표시 */
23
25
  this.unit = '%';
24
- /** 차트 제목 */
25
26
  this.kpiName = '';
26
27
  this.chartWidth = 0;
27
28
  this.chartHeight = 0;
@@ -57,353 +58,164 @@ let Kpi2dLookupChart = class Kpi2dLookupChart extends LitElement {
57
58
  updated() {
58
59
  this.renderChart();
59
60
  }
61
+ getScore(row, deviation) {
62
+ if (deviation < row.boundary5to4)
63
+ return 5;
64
+ if (deviation < row.boundary4to3)
65
+ return 4;
66
+ if (deviation < row.boundary3to2)
67
+ return 3;
68
+ if (deviation < row.boundary2to1)
69
+ return 2;
70
+ return 1;
71
+ }
72
+ getCurrentScore() {
73
+ var _a;
74
+ if (this.value === null || !((_a = this.grades) === null || _a === void 0 ? void 0 : _a.rows))
75
+ return null;
76
+ const idx = Math.min(99, Math.max(0, Math.floor(this.progressRate)));
77
+ const row = this.grades.rows.find(r => r.progressRate === idx);
78
+ return row ? this.getScore(row, this.value) : null;
79
+ }
60
80
  renderChart() {
81
+ var _a, _b;
61
82
  const svg = d3.select(this.renderRoot.querySelector('#lookup-2d-chart'));
62
83
  svg.selectAll('*').remove();
63
- if (!this.grades || !this.grades.rows || this.grades.rows.length === 0) {
64
- this.drawEmptyState(svg);
84
+ if (!((_b = (_a = this.grades) === null || _a === void 0 ? void 0 : _a.rows) === null || _b === void 0 ? void 0 : _b.length)) {
85
+ svg.append('text')
86
+ .attr('x', this.chartWidth / 2).attr('y', this.chartHeight / 2)
87
+ .attr('text-anchor', 'middle').attr('fill', '#999').attr('font-size', '14px')
88
+ .text('No 2D grade data available');
65
89
  return;
66
90
  }
67
91
  const w = this.chartWidth || 400;
68
92
  const h = this.chartHeight || 250;
69
- const margin = { top: 45, right: 30, bottom: 55, left: 65 };
93
+ const margin = { top: 50, right: 80, bottom: 50, left: 60 };
70
94
  const plotW = w - margin.left - margin.right;
71
95
  const plotH = h - margin.top - margin.bottom;
72
96
  if (plotW <= 0 || plotH <= 0)
73
97
  return;
74
98
  const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`);
75
- // 제목
76
- if (this.kpiName) {
77
- svg
78
- .append('text')
79
- .attr('class', 'chart-title')
80
- .attr('x', w / 2)
81
- .attr('y', 15)
82
- .attr('text-anchor', 'middle')
83
- .text(this.kpiName);
84
- }
85
- // 헤더 (현재 값, 점수)
86
- this.drawHeader(svg, w);
87
- // 차트 본체
88
- this.draw2DChart(g, plotW, plotH);
89
- }
90
- /**
91
- * 현재 공정률에서의 점수 계산
92
- */
93
- getCurrentScore() {
94
- var _a;
95
- if (this.value === null || !((_a = this.grades) === null || _a === void 0 ? void 0 : _a.rows))
96
- return null;
97
- const progressIdx = Math.min(99, Math.max(0, Math.floor(this.progressRate)));
98
- const row = this.grades.rows.find(r => r.progressRate === progressIdx);
99
- if (!row)
100
- return null;
101
- if (this.value < row.boundary5to4)
102
- return 5;
103
- if (this.value < row.boundary4to3)
104
- return 4;
105
- if (this.value < row.boundary3to2)
106
- return 3;
107
- if (this.value < row.boundary2to1)
108
- return 2;
109
- return 1;
110
- }
111
- drawHeader(svg, chartWidth) {
112
- const headerY = 32;
113
- // 편차율(value) 표시
99
+ const rows = this.grades.rows;
100
+ // Y축 범위 (편차율)
101
+ const allBoundaries = rows.flatMap(r => [r.boundary5to4, r.boundary4to3, r.boundary3to2, r.boundary2to1]);
102
+ let yMin = Math.min(...allBoundaries);
103
+ let yMax = Math.max(...allBoundaries);
114
104
  if (this.value !== null) {
115
- svg
116
- .append('text')
117
- .attr('class', 'value-label')
118
- .attr('x', 10)
119
- .attr('y', headerY)
120
- .attr('font-size', '11px')
121
- .text(`편차율: ${(this.value * 100).toFixed(2)}%`);
105
+ yMin = Math.min(yMin, this.value);
106
+ yMax = Math.max(yMax, this.value);
122
107
  }
123
- // 공정률 표시
124
- svg
125
- .append('text')
126
- .attr('class', 'progress-label')
127
- .attr('x', chartWidth / 2)
128
- .attr('y', headerY)
129
- .attr('text-anchor', 'middle')
130
- .attr('font-size', '11px')
131
- .text(`공정률: ${this.progressRate.toFixed(0)}%`);
132
- // Score 표시
133
- const currentScore = this.getCurrentScore();
134
- if (currentScore !== null) {
135
- const normalized = currentScore / 5;
136
- svg
137
- .append('text')
138
- .attr('class', 'value-label')
139
- .attr('x', chartWidth - 10)
140
- .attr('y', headerY)
141
- .attr('text-anchor', 'end')
142
- .attr('font-size', '11px')
143
- .text(`성과수준: ${normalized.toFixed(1)} (${currentScore}점)`);
108
+ const yPad = (yMax - yMin) * 0.1 || 0.01;
109
+ yMin -= yPad;
110
+ yMax += yPad;
111
+ // X축: 공정률 0~100
112
+ const xScale = d3.scaleLinear().domain([0, 100]).range([0, plotW]);
113
+ const yScale = d3.scaleLinear().domain([yMin, yMax]).range([plotH, 0]);
114
+ // 히트맵: 각 공정률(x) × 편차율(y) 셀의 점수를 색상으로
115
+ const xSteps = rows.map(r => r.progressRate).sort((a, b) => a - b);
116
+ const yStepCount = 50; // Y축 해상도
117
+ const yStep = (yMax - yMin) / yStepCount;
118
+ const xCellW = plotW / Math.max(xSteps.length, 1);
119
+ for (const row of rows) {
120
+ const xi = xScale(row.progressRate);
121
+ for (let j = 0; j < yStepCount; j++) {
122
+ const dev = yMin + j * yStep;
123
+ const score = this.getScore(row, dev);
124
+ g.append('rect')
125
+ .attr('x', xi)
126
+ .attr('y', yScale(dev + yStep))
127
+ .attr('width', xCellW + 0.5) // 약간 겹쳐서 틈새 방지
128
+ .attr('height', Math.abs(yScale(dev) - yScale(dev + yStep)) + 0.5)
129
+ .attr('fill', SCORE_COLORS[score])
130
+ .attr('opacity', 0.35);
131
+ }
144
132
  }
145
- }
146
- draw2DChart(g, width, height) {
147
- const rows = this.grades.rows;
148
- // --- X축 범위 계산 (편차율) ---
149
- // 모든 공정률에서의 최소/최대 경계값
150
- const allBoundaries = rows.flatMap(r => [r.boundary5to4, r.boundary2to1]);
151
- let xMin = Math.min(...allBoundaries);
152
- let xMax = Math.max(...allBoundaries);
153
- // 현재 value 포함
133
+ // 경계선: 현재 공정률에서의 점수 전환선
134
+ const currentIdx = Math.min(99, Math.max(0, Math.floor(this.progressRate)));
135
+ const currentRow = rows.find(r => r.progressRate === currentIdx);
136
+ if (currentRow) {
137
+ const boundaries = [currentRow.boundary5to4, currentRow.boundary4to3, currentRow.boundary3to2, currentRow.boundary2to1];
138
+ boundaries.forEach(bdy => {
139
+ g.append('line')
140
+ .attr('x1', xScale(this.progressRate)).attr('x2', xScale(this.progressRate))
141
+ .attr('y1', yScale(bdy) - 3).attr('y2', yScale(bdy) + 3)
142
+ .attr('stroke', '#333').attr('stroke-width', 2);
143
+ });
144
+ }
145
+ // 현재 공정률 수직선
146
+ g.append('line')
147
+ .attr('x1', xScale(this.progressRate)).attr('x2', xScale(this.progressRate))
148
+ .attr('y1', 0).attr('y2', plotH)
149
+ .attr('stroke', '#1565c0').attr('stroke-width', 1.5).attr('stroke-dasharray', '4,3');
150
+ // 현재 위치 포인트
154
151
  if (this.value !== null) {
155
- xMin = Math.min(xMin, this.value);
156
- xMax = Math.max(xMax, this.value);
152
+ g.append('circle')
153
+ .attr('cx', xScale(this.progressRate))
154
+ .attr('cy', yScale(this.value))
155
+ .attr('r', 7)
156
+ .attr('fill', '#e53935').attr('stroke', '#fff').attr('stroke-width', 2.5);
157
+ // 현재 편차율 수평선
158
+ g.append('line')
159
+ .attr('x1', 0).attr('x2', xScale(this.progressRate))
160
+ .attr('y1', yScale(this.value)).attr('y2', yScale(this.value))
161
+ .attr('stroke', '#e53935').attr('stroke-width', 1).attr('stroke-dasharray', '3,3');
157
162
  }
158
- // 약간의 여백
159
- const xPadding = (xMax - xMin) * 0.08;
160
- xMin -= xPadding;
161
- xMax += xPadding;
162
- const xScale = d3.scaleLinear().domain([xMin, xMax]).range([0, width]);
163
- // --- Y축 (성과수준 0~1) ---
164
- const yScale = d3.scaleLinear().domain([0, 1.05]).range([height, 0]);
165
- // --- 반투명 밴드: 모든 공정률에서의 경계 변동 범위 ---
166
- this.drawBands(g, rows, xScale, yScale, width);
167
- // --- 현재 공정률 기준 계단 함수 ---
168
- this.drawStepFunction(g, xScale, yScale, xMin, xMax);
169
- // --- 현재 위치 포인터 ---
170
- this.drawCurrentValuePointer(g, xScale, yScale);
171
- // --- 축 그리기 ---
172
- // Y축
173
- const yAxis = d3.axisLeft(yScale).tickValues([0, 0.2, 0.4, 0.6, 0.8, 1.0]);
174
- g.append('g').call(yAxis);
163
+ //
164
+ g.append('g')
165
+ .attr('transform', `translate(0,${plotH})`)
166
+ .call(d3.axisBottom(xScale).ticks(10).tickFormat((d) => `${d}%`))
167
+ .selectAll('text').attr('font-size', '10px');
168
+ g.append('g')
169
+ .call(d3.axisLeft(yScale).ticks(8))
170
+ .selectAll('text').attr('font-size', '10px');
171
+ // 라벨
175
172
  g.append('text')
176
- .attr('class', 'axis-label')
177
- .attr('transform', 'rotate(-90)')
178
- .attr('x', -height / 2)
179
- .attr('y', -50)
180
- .attr('text-anchor', 'middle')
181
- .attr('font-weight', 'bold')
182
- .text('성과수준');
183
- // X축
184
- const xAxis = d3
185
- .axisBottom(xScale)
186
- .ticks(8)
187
- .tickFormat((d) => `${(d * 100).toFixed(1)}%`);
188
- g.append('g').attr('transform', `translate(0,${height})`).call(xAxis);
173
+ .attr('x', plotW / 2).attr('y', plotH + 38)
174
+ .attr('text-anchor', 'middle').attr('font-size', '11px').attr('fill', '#666')
175
+ .text('공정률 (%)');
189
176
  g.append('text')
190
- .attr('class', 'axis-label')
191
- .attr('x', width / 2)
192
- .attr('y', height + 42)
193
- .attr('text-anchor', 'middle')
194
- .text('편차율');
195
- // --- 점수 라벨 (우측) ---
196
- SCORE_LEVELS.forEach(level => {
197
- g.append('text')
198
- .attr('class', 'score-label')
199
- .attr('x', width + 5)
200
- .attr('y', yScale(level.normalized) + 4)
201
- .attr('fill', level.color)
202
- .text(level.label);
203
- });
204
- }
205
- /**
206
- * 반투명 밴드: 공정률 0~100% 전체에서 각 경계가 이동하는 범위
207
- */
208
- drawBands(g, rows, xScale, yScale, width) {
209
- // 각 점수 경계별로 min/max 범위 계산
210
- const b5to4_min = Math.min(...rows.map(r => r.boundary5to4));
211
- const b5to4_max = Math.max(...rows.map(r => r.boundary5to4));
212
- const b3to2_min = Math.min(...rows.map(r => r.boundary3to2));
213
- const b3to2_max = Math.max(...rows.map(r => r.boundary3to2));
214
- const b2to1_min = Math.min(...rows.map(r => r.boundary2to1));
215
- const b2to1_max = Math.max(...rows.map(r => r.boundary2to1));
216
- // 5점 영역 (맨 왼쪽)
217
- g.append('rect')
218
- .attr('x', 0)
219
- .attr('y', yScale(1.0))
220
- .attr('width', xScale(b5to4_max) - xScale(xScale.domain()[0]))
221
- .attr('height', yScale(0.8) - yScale(1.0))
222
- .attr('fill', SCORE_LEVELS[0].color)
223
- .attr('opacity', 0.06);
224
- // 4점 영역
225
- g.append('rect')
226
- .attr('x', xScale(b5to4_min))
227
- .attr('y', yScale(0.8))
228
- .attr('width', xScale(0) - xScale(b5to4_min))
229
- .attr('height', yScale(0.6) - yScale(0.8))
230
- .attr('fill', SCORE_LEVELS[1].color)
231
- .attr('opacity', 0.06);
232
- // 3점 영역
233
- g.append('rect')
234
- .attr('x', xScale(0))
235
- .attr('y', yScale(0.6))
236
- .attr('width', xScale(b3to2_max) - xScale(0))
237
- .attr('height', yScale(0.4) - yScale(0.6))
238
- .attr('fill', SCORE_LEVELS[2].color)
239
- .attr('opacity', 0.06);
240
- // 2점 영역
241
- g.append('rect')
242
- .attr('x', xScale(b3to2_min))
243
- .attr('y', yScale(0.4))
244
- .attr('width', xScale(b2to1_max) - xScale(b3to2_min))
245
- .attr('height', yScale(0.2) - yScale(0.4))
246
- .attr('fill', SCORE_LEVELS[3].color)
247
- .attr('opacity', 0.06);
248
- // 1점 영역 (맨 오른쪽)
249
- g.append('rect')
250
- .attr('x', xScale(b2to1_min))
251
- .attr('y', yScale(0.2))
252
- .attr('width', width - xScale(b2to1_min))
253
- .attr('height', yScale(0) - yScale(0.2))
254
- .attr('fill', SCORE_LEVELS[4].color)
255
- .attr('opacity', 0.06);
256
- // 경계 변동 범위를 수직 반투명 밴드로 표시
257
- const boundaryRanges = [
258
- { min: b5to4_min, max: b5to4_max, color: '#666' },
259
- { min: b3to2_min, max: b3to2_max, color: '#666' },
260
- { min: b2to1_min, max: b2to1_max, color: '#666' }
261
- ];
262
- boundaryRanges.forEach(br => {
263
- if (Math.abs(br.max - br.min) > 0.0001) {
264
- g.append('rect')
265
- .attr('x', xScale(br.min))
266
- .attr('y', 0)
267
- .attr('width', xScale(br.max) - xScale(br.min))
268
- .attr('height', yScale(0))
269
- .attr('fill', br.color)
270
- .attr('opacity', 0.05);
271
- }
272
- });
273
- }
274
- /**
275
- * 현재 공정률 기준 계단 함수 그리기
276
- */
277
- drawStepFunction(g, xScale, yScale, xMin, xMax) {
278
- var _a;
279
- const progressIdx = Math.min(99, Math.max(0, Math.floor(this.progressRate)));
280
- const row = (_a = this.grades) === null || _a === void 0 ? void 0 : _a.rows.find(r => r.progressRate === progressIdx);
281
- if (!row)
282
- return;
283
- const boundaries = [
284
- { x: xMin, score: 1.0 }, // 5점 시작 (왼쪽 끝)
285
- { x: row.boundary5to4, score: 1.0 }, // 5점→4점 전환점
286
- { x: row.boundary5to4, score: 0.8 }, // 4점 시작
287
- { x: row.boundary4to3, score: 0.8 }, // 4점→3점 전환점
288
- { x: row.boundary4to3, score: 0.6 }, // 3점 시작
289
- { x: row.boundary3to2, score: 0.6 }, // 3점→2점 전환점
290
- { x: row.boundary3to2, score: 0.4 }, // 2점 시작
291
- { x: row.boundary2to1, score: 0.4 }, // 2점→1점 전환점
292
- { x: row.boundary2to1, score: 0.2 }, // 1점 시작
293
- { x: xMax, score: 0.2 } // 1점 끝 (오른쪽 끝)
294
- ];
295
- // 계단 함수 영역 채우기 (반투명)
296
- const areaData = [...boundaries];
297
- const areaBottom = [...boundaries].reverse().map(b => ({ x: b.x, score: 0 }));
298
- const area = d3
299
- .area()
300
- .x(d => xScale(d.x))
301
- .y0(yScale(0))
302
- .y1(d => yScale(d.score));
303
- g.append('path')
304
- .datum(boundaries)
305
- .attr('d', area)
306
- .attr('fill', '#1565c0')
307
- .attr('opacity', 0.1);
308
- // 계단 함수 라인
309
- const line = d3
310
- .line()
311
- .x(d => xScale(d.x))
312
- .y(d => yScale(d.score));
313
- g.append('path')
314
- .datum(boundaries)
315
- .attr('d', line)
316
- .attr('fill', 'none')
317
- .attr('stroke', '#1565c0')
318
- .attr('stroke-width', 2.5);
319
- // 경계 전환점에 수직 점선
320
- const verticalBoundaries = [row.boundary5to4, row.boundary4to3, row.boundary3to2, row.boundary2to1];
321
- const verticalScorePairs = [
322
- [1.0, 0.8],
323
- [0.8, 0.6],
324
- [0.6, 0.4],
325
- [0.4, 0.2]
326
- ];
327
- verticalBoundaries.forEach((bx, i) => {
328
- // 수직 점선 (X축까지)
329
- g.append('line')
330
- .attr('x1', xScale(bx))
331
- .attr('x2', xScale(bx))
332
- .attr('y1', yScale(verticalScorePairs[i][0]))
333
- .attr('y2', yScale(0))
334
- .attr('stroke', '#999')
335
- .attr('stroke-width', 0.8)
336
- .attr('stroke-dasharray', '3,3');
337
- // 경계값 라벨 (X축 아래)
177
+ .attr('transform', 'rotate(-90)')
178
+ .attr('x', -plotH / 2).attr('y', -45)
179
+ .attr('text-anchor', 'middle').attr('font-size', '11px').attr('fill', '#666')
180
+ .text('공기편차 (%)');
181
+ // 범례
182
+ const legendX = plotW + 15;
183
+ const legendY = 10;
184
+ [5, 4, 3, 2, 1].forEach((score, i) => {
185
+ g.append('rect')
186
+ .attr('x', legendX).attr('y', legendY + i * 22)
187
+ .attr('width', 14).attr('height', 14)
188
+ .attr('fill', SCORE_COLORS[score]).attr('opacity', 0.7).attr('rx', 2);
338
189
  g.append('text')
339
- .attr('class', 'axis-label')
340
- .attr('x', xScale(bx))
341
- .attr('y', yScale(0) + 12)
342
- .attr('text-anchor', 'middle')
343
- .attr('font-size', '9px')
344
- .attr('fill', '#1565c0')
345
- .text(`${(bx * 100).toFixed(1)}%`);
190
+ .attr('x', legendX + 20).attr('y', legendY + i * 22 + 11)
191
+ .attr('font-size', '10px').attr('fill', '#555')
192
+ .text(`${score}점`);
346
193
  });
347
- }
348
- /**
349
- * 현재 프로젝트 위치 표시
350
- */
351
- drawCurrentValuePointer(g, xScale, yScale) {
352
- if (this.value === null)
353
- return;
194
+ // 헤더
354
195
  const currentScore = this.getCurrentScore();
355
- if (currentScore === null)
356
- return;
357
- const normalizedScore = currentScore / 5;
358
- const cx = xScale(this.value);
359
- const cy = yScale(normalizedScore);
360
- // 수평 가이드라인 (Y축까지)
361
- g.append('line')
362
- .attr('x1', 0)
363
- .attr('x2', cx)
364
- .attr('y1', cy)
365
- .attr('y2', cy)
366
- .attr('stroke', '#e53935')
367
- .attr('stroke-width', 1)
368
- .attr('stroke-dasharray', '4,3')
369
- .attr('opacity', 0.6);
370
- // 수직 가이드라인 (X축까지)
371
- g.append('line')
372
- .attr('x1', cx)
373
- .attr('x2', cx)
374
- .attr('y1', cy)
375
- .attr('y2', yScale(0))
376
- .attr('stroke', '#e53935')
377
- .attr('stroke-width', 1)
378
- .attr('stroke-dasharray', '4,3')
379
- .attr('opacity', 0.6);
380
- // 포인터 원
381
- g.append('circle')
382
- .attr('cx', cx)
383
- .attr('cy', cy)
384
- .attr('r', 7)
385
- .attr('fill', '#e53935')
386
- .attr('stroke', '#fff')
387
- .attr('stroke-width', 2.5);
388
- // 포인터 위에 값 라벨
389
- g.append('text')
390
- .attr('x', cx)
391
- .attr('y', cy - 14)
392
- .attr('text-anchor', 'middle')
393
- .attr('font-size', '11px')
394
- .attr('font-weight', '700')
395
- .attr('fill', '#e53935')
396
- .text(`${(this.value * 100).toFixed(2)}% → ${normalizedScore.toFixed(1)}`);
397
- }
398
- drawEmptyState(svg) {
399
- svg
400
- .append('text')
401
- .attr('x', this.chartWidth / 2)
402
- .attr('y', this.chartHeight / 2)
403
- .attr('text-anchor', 'middle')
404
- .attr('fill', '#999')
405
- .attr('font-size', '14px')
406
- .text('No 2D grade data available');
196
+ if (this.kpiName) {
197
+ svg.append('text')
198
+ .attr('x', w / 2).attr('y', 18)
199
+ .attr('text-anchor', 'middle').attr('font-size', '13px').attr('font-weight', '600').attr('fill', '#333')
200
+ .text(this.kpiName);
201
+ }
202
+ const headerY = 38;
203
+ if (this.value !== null) {
204
+ svg.append('text')
205
+ .attr('x', margin.left).attr('y', headerY)
206
+ .attr('font-size', '11px').attr('font-weight', 'bold').attr('fill', '#e53935')
207
+ .text(`편차율: ${(this.value * 100).toFixed(2)}%`);
208
+ }
209
+ svg.append('text')
210
+ .attr('x', w / 2).attr('y', headerY)
211
+ .attr('text-anchor', 'middle').attr('font-size', '11px').attr('font-weight', '600').attr('fill', '#1565c0')
212
+ .text(`공정률: ${this.progressRate.toFixed(0)}%`);
213
+ if (currentScore !== null) {
214
+ svg.append('text')
215
+ .attr('x', w - margin.right).attr('y', headerY)
216
+ .attr('text-anchor', 'end').attr('font-size', '11px').attr('font-weight', 'bold').attr('fill', SCORE_COLORS[currentScore])
217
+ .text(`${currentScore}점`);
218
+ }
407
219
  }
408
220
  };
409
221
  Kpi2dLookupChart.styles = css `
@@ -418,30 +230,6 @@ Kpi2dLookupChart.styles = css `
418
230
  height: 100%;
419
231
  display: block;
420
232
  }
421
- .chart-title {
422
- font-size: 14px;
423
- font-weight: 600;
424
- fill: #333;
425
- }
426
- .value-label {
427
- font-size: 12px;
428
- font-weight: 600;
429
- fill: #e53935;
430
- }
431
- .axis-label {
432
- font-size: 11px;
433
- fill: #999;
434
- }
435
- .score-label {
436
- font-size: 10px;
437
- fill: #666;
438
- font-weight: 500;
439
- }
440
- .progress-label {
441
- font-size: 10px;
442
- fill: #1565c0;
443
- font-weight: 600;
444
- }
445
233
  `;
446
234
  __decorate([
447
235
  property({ type: Object }),