@things-factory/kpi 9.0.29 → 9.0.31

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 (73) hide show
  1. package/client/charts/kpi-mini-trend-chart.ts +125 -0
  2. package/client/charts/kpi-trend-chart.ts +163 -0
  3. package/client/google-map/common-google-map.ts +370 -0
  4. package/client/google-map/google-map-loader.ts +29 -0
  5. package/client/pages/kpi-dashboard/cards/kpi-level1-card.ts +248 -0
  6. package/client/pages/kpi-dashboard/cards/kpi-level2-comparison.ts +369 -0
  7. package/client/pages/kpi-dashboard/cards/kpi-level3-comparison.ts +443 -0
  8. package/client/pages/kpi-dashboard/components/kpi-chart-toggle.ts +72 -0
  9. package/client/pages/kpi-dashboard/components/kpi-left-panel.ts +399 -0
  10. package/client/pages/kpi-dashboard/components/kpi-map-panel.ts +302 -0
  11. package/client/pages/kpi-dashboard/components/kpi-region-popup.ts +355 -0
  12. package/client/pages/kpi-dashboard/kpi-dashboard-map.ts +243 -0
  13. package/client/pages/kpi-dashboard/kpi-dashboard.ts +416 -0
  14. package/client/route.ts +4 -0
  15. package/dist-client/charts/kpi-mini-trend-chart.d.ts +14 -0
  16. package/dist-client/charts/kpi-mini-trend-chart.js +148 -0
  17. package/dist-client/charts/kpi-mini-trend-chart.js.map +1 -0
  18. package/dist-client/charts/kpi-trend-chart.d.ts +25 -0
  19. package/dist-client/charts/kpi-trend-chart.js +186 -0
  20. package/dist-client/charts/kpi-trend-chart.js.map +1 -0
  21. package/dist-client/google-map/common-google-map.d.ts +34 -0
  22. package/dist-client/google-map/common-google-map.js +333 -0
  23. package/dist-client/google-map/common-google-map.js.map +1 -0
  24. package/dist-client/google-map/google-map-loader.d.ts +6 -0
  25. package/dist-client/google-map/google-map-loader.js +22 -0
  26. package/dist-client/google-map/google-map-loader.js.map +1 -0
  27. package/dist-client/pages/kpi-dashboard/cards/kpi-level1-card.d.ts +17 -0
  28. package/dist-client/pages/kpi-dashboard/cards/kpi-level1-card.js +279 -0
  29. package/dist-client/pages/kpi-dashboard/cards/kpi-level1-card.js.map +1 -0
  30. package/dist-client/pages/kpi-dashboard/cards/kpi-level2-comparison.d.ts +19 -0
  31. package/dist-client/pages/kpi-dashboard/cards/kpi-level2-comparison.js +385 -0
  32. package/dist-client/pages/kpi-dashboard/cards/kpi-level2-comparison.js.map +1 -0
  33. package/dist-client/pages/kpi-dashboard/cards/kpi-level3-comparison.d.ts +23 -0
  34. package/dist-client/pages/kpi-dashboard/cards/kpi-level3-comparison.js +465 -0
  35. package/dist-client/pages/kpi-dashboard/cards/kpi-level3-comparison.js.map +1 -0
  36. package/dist-client/pages/kpi-dashboard/components/kpi-chart-toggle.d.ts +8 -0
  37. package/dist-client/pages/kpi-dashboard/components/kpi-chart-toggle.js +78 -0
  38. package/dist-client/pages/kpi-dashboard/components/kpi-chart-toggle.js.map +1 -0
  39. package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.d.ts +22 -0
  40. package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js +404 -0
  41. package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js.map +1 -0
  42. package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.d.ts +28 -0
  43. package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.js +298 -0
  44. package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.js.map +1 -0
  45. package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.d.ts +23 -0
  46. package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js +368 -0
  47. package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js.map +1 -0
  48. package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.d.ts +29 -0
  49. package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js +271 -0
  50. package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js.map +1 -0
  51. package/dist-client/pages/kpi-dashboard/kpi-dashboard.d.ts +21 -0
  52. package/dist-client/pages/kpi-dashboard/kpi-dashboard.js +398 -0
  53. package/dist-client/pages/kpi-dashboard/kpi-dashboard.js.map +1 -1
  54. package/dist-client/route.d.ts +1 -1
  55. package/dist-client/route.js +3 -0
  56. package/dist-client/route.js.map +1 -1
  57. package/dist-client/tsconfig.tsbuildinfo +1 -1
  58. package/dist-server/index.d.ts +1 -0
  59. package/dist-server/index.js +1 -0
  60. package/dist-server/index.js.map +1 -1
  61. package/dist-server/migrations/index.d.ts +1 -0
  62. package/dist-server/migrations/index.js +12 -0
  63. package/dist-server/migrations/index.js.map +1 -0
  64. package/dist-server/tsconfig.tsbuildinfo +1 -1
  65. package/package.json +2 -2
  66. package/server/index.ts +1 -0
  67. package/server/migrations/index.ts +9 -0
  68. package/things-factory.config.js +2 -1
  69. package/translations/en.json +1 -0
  70. package/translations/ja.json +1 -0
  71. package/translations/ko.json +1 -0
  72. package/translations/ms.json +1 -0
  73. package/translations/zh.json +1 -0
@@ -0,0 +1,399 @@
1
+ import { LitElement, html, css } from 'lit'
2
+ import { customElement, property, state } from 'lit/decorators.js'
3
+
4
+ import '../../../charts/kpi-radar-chart.js'
5
+ import '../../../charts/kpi-boxplot-chart.js'
6
+ import '../../../charts/kpi-mini-trend-chart.js'
7
+
8
+ @customElement('kpi-left-panel')
9
+ export class KpiLeftPanel extends LitElement {
10
+ static styles = css`
11
+ :host {
12
+ display: block;
13
+ width: 400px;
14
+ background: #fff;
15
+ border-right: 1px solid #e0e0e0;
16
+ overflow: hidden;
17
+ display: flex;
18
+ flex-direction: column;
19
+ box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
20
+ }
21
+ .panel-content {
22
+ padding: 24px;
23
+ overflow-y: auto;
24
+ flex: 1;
25
+ }
26
+ .panel-header {
27
+ display: flex;
28
+ justify-content: space-between;
29
+ align-items: center;
30
+ padding: 20px 24px;
31
+ border-bottom: 1px solid #e0e0e0;
32
+ background: #fff;
33
+ height: 70px;
34
+ box-sizing: border-box;
35
+ }
36
+ .panel-title {
37
+ font-size: 1.3rem;
38
+ font-weight: bold;
39
+ color: #333;
40
+ margin: 0;
41
+ }
42
+ .panel-close {
43
+ width: 32px;
44
+ height: 32px;
45
+ border: none;
46
+ background: #fff;
47
+ border-radius: 50%;
48
+ cursor: pointer;
49
+ display: flex;
50
+ align-items: center;
51
+ justify-content: center;
52
+ font-size: 1.2rem;
53
+ color: #666;
54
+ transition: all 0.2s;
55
+ }
56
+ .panel-close:hover {
57
+ background: #e9ecef;
58
+ color: #333;
59
+ }
60
+ .sub-title {
61
+ font-size: 1rem;
62
+ font-weight: 600;
63
+ margin-bottom: 16px;
64
+ color: #495057;
65
+ }
66
+ .chart-section {
67
+ background: #f8f9fa;
68
+ border-radius: 8px;
69
+ padding: 16px;
70
+ margin-bottom: 20px;
71
+ }
72
+ .chart-toggle {
73
+ display: flex;
74
+ gap: 8px;
75
+ margin-bottom: 16px;
76
+ }
77
+ .toggle-button {
78
+ padding: 8px 16px;
79
+ border: 1px solid #ced4da;
80
+ background: #fff;
81
+ border-radius: 6px;
82
+ cursor: pointer;
83
+ font-size: 0.9rem;
84
+ transition: all 0.2s;
85
+ }
86
+ .toggle-button.active {
87
+ background: #667eea;
88
+ color: white;
89
+ border-color: #667eea;
90
+ }
91
+ .chart-container {
92
+ height: 300px;
93
+ display: flex;
94
+ align-items: center;
95
+ justify-content: center;
96
+ background: white;
97
+ border-radius: 6px;
98
+ border: 1px solid #e9ecef;
99
+ }
100
+ .performance-table {
101
+ width: 100%;
102
+ border-collapse: collapse;
103
+ margin-top: 16px;
104
+ }
105
+ .performance-table th,
106
+ .performance-table td {
107
+ padding: 12px 8px;
108
+ text-align: left;
109
+ border-bottom: 1px solid #e9ecef;
110
+ }
111
+ .performance-table th {
112
+ background: #f8f9fa;
113
+ font-weight: 600;
114
+ color: #495057;
115
+ }
116
+ .performance-table td {
117
+ color: #333;
118
+ }
119
+ .change-rate {
120
+ display: flex;
121
+ align-items: center;
122
+ gap: 4px;
123
+ }
124
+ .change-up {
125
+ color: #dc3545;
126
+ }
127
+ .change-down {
128
+ color: #198754;
129
+ }
130
+ .change-neutral {
131
+ color: #6c757d;
132
+ }
133
+ .trend-chart {
134
+ width: 60px;
135
+ height: 30px;
136
+ background: #f8f9fa;
137
+ border-radius: 4px;
138
+ display: flex;
139
+ align-items: center;
140
+ justify-content: center;
141
+ font-size: 0.8rem;
142
+ color: #666;
143
+ }
144
+ .download-button {
145
+ margin-top: 16px;
146
+ padding: 8px 16px;
147
+ background: #28a745;
148
+ color: white;
149
+ border: none;
150
+ border-radius: 6px;
151
+ cursor: pointer;
152
+ font-size: 0.9rem;
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 8px;
156
+ }
157
+ .download-button:hover {
158
+ background: #218838;
159
+ }
160
+ .category-select {
161
+ width: 100%;
162
+ padding: 8px 12px;
163
+ border: 1px solid #ced4da;
164
+ border-radius: 6px;
165
+ background: white;
166
+ margin-bottom: 20px;
167
+ }
168
+ `
169
+
170
+ @property({ type: String }) selectedCategory = '전체 KPI'
171
+ @property({ type: String }) selectedChartType = 'boxplot'
172
+ @property({ type: Array }) mapData: any[] = []
173
+
174
+ @state() private chartData: any[] = []
175
+ @state() private chartCategories: string[] = []
176
+
177
+ connectedCallback() {
178
+ super.connectedCallback()
179
+ this.generateChartData()
180
+ }
181
+
182
+ private generateTrendData(region: string): number[] {
183
+ // 각 지역별로 7일간의 트렌드 데이터 생성
184
+ const baseValue = Math.random() * 30 + 60 // 60-90 범위
185
+ const trendData: number[] = []
186
+
187
+ for (let i = 0; i < 7; i++) {
188
+ const trend = Math.sin(i * 0.5) * 5 // 사인파로 변동
189
+ const noise = (Math.random() - 0.5) * 3 // 랜덤 노이즈
190
+ const value = Math.max(0, Math.min(100, baseValue + trend + noise))
191
+ trendData.push(Math.round(value))
192
+ }
193
+
194
+ return trendData
195
+ }
196
+
197
+ private generateChartData() {
198
+ // 선택된 카테고리에 따른 차트 데이터 생성
199
+ const categories = ['일정 성과', '비용 성과', '품질 성과', '안전 성과', '환경 성과']
200
+ this.chartCategories = categories
201
+
202
+ if (this.selectedChartType === 'radar') {
203
+ // 레이더 차트용 데이터 생성
204
+ this.chartData = categories.map(category => ({
205
+ category,
206
+ value: Math.random() * 50 + 25, // 25-75 범위
207
+ group: this.selectedCategory
208
+ }))
209
+ } else {
210
+ // 박스플롯용 데이터 생성
211
+ this.chartData = categories.map(category => {
212
+ const baseValue = Math.random() * 50 + 25 // 25-75 범위
213
+ const variation = Math.random() * 20 // 변동폭
214
+
215
+ // 각 카테고리별로 20개의 데이터 포인트 생성
216
+ const dataPoints: { value: number; group: string }[] = []
217
+ for (let i = 0; i < 20; i++) {
218
+ dataPoints.push({
219
+ value: baseValue + (Math.random() - 0.5) * variation,
220
+ group: category
221
+ })
222
+ }
223
+
224
+ // 통계값 계산
225
+ const values = dataPoints.map(d => d.value).sort((a, b) => a - b)
226
+ const min = Math.min(...values)
227
+ const max = Math.max(...values)
228
+ const q1 = values[Math.floor(values.length * 0.25)]
229
+ const q3 = values[Math.floor(values.length * 0.75)]
230
+ const median = values[Math.floor(values.length * 0.5)]
231
+ const mean = values.reduce((a, b) => a + b, 0) / values.length
232
+
233
+ return {
234
+ group: category,
235
+ min: min,
236
+ max: max,
237
+ q1: q1,
238
+ q3: q3,
239
+ median: median,
240
+ mean: mean,
241
+ value: mean
242
+ }
243
+ })
244
+ }
245
+ }
246
+
247
+ private onCategoryChange(event: Event) {
248
+ const target = event.target as HTMLSelectElement
249
+ this.dispatchEvent(
250
+ new CustomEvent('category-change', {
251
+ detail: { category: target.value },
252
+ bubbles: true,
253
+ composed: true
254
+ })
255
+ )
256
+ this.generateChartData()
257
+ }
258
+
259
+ private onChartTypeChange(type: string) {
260
+ this.selectedChartType = type
261
+ this.generateChartData()
262
+ }
263
+
264
+ private onRegionClick(region: string) {
265
+ this.dispatchEvent(
266
+ new CustomEvent('region-click', {
267
+ detail: { region },
268
+ bubbles: true,
269
+ composed: true
270
+ })
271
+ )
272
+ }
273
+
274
+ private downloadExcel() {
275
+ this.dispatchEvent(
276
+ new CustomEvent('download-excel', {
277
+ bubbles: true,
278
+ composed: true
279
+ })
280
+ )
281
+ }
282
+
283
+ private getChangeRateClass(change: number): string {
284
+ if (change > 0) return 'change-up'
285
+ if (change < 0) return 'change-down'
286
+ return 'change-neutral'
287
+ }
288
+
289
+ private getChangeIcon(change: number): string {
290
+ if (change > 0) return '▲'
291
+ if (change < 0) return '▼'
292
+ return '─'
293
+ }
294
+
295
+ render() {
296
+ return html`
297
+ <div class="panel-header">
298
+ <div class="panel-title">전국 KPI</div>
299
+ <button class="panel-close" style="visibility: hidden;">×</button>
300
+ </div>
301
+ <div class="panel-content">
302
+ <!-- KPI 카테고리 선택 -->
303
+ <select class="category-select" .value=${this.selectedCategory} @change=${this.onCategoryChange}>
304
+ <option value="전체 KPI">전체 KPI</option>
305
+ <option value="일정 성과">일정 성과</option>
306
+ <option value="비용 성과">비용 성과</option>
307
+ <option value="품질 성과">품질 성과</option>
308
+ <option value="안전 성과">안전 성과</option>
309
+ <option value="환경 성과">환경 성과</option>
310
+ </select>
311
+
312
+ <!-- 종합 성과 -->
313
+ <div class="chart-section">
314
+ <div class="sub-title">종합 성과</div>
315
+ <div class="chart-toggle">
316
+ <button
317
+ class="toggle-button ${this.selectedChartType === 'boxplot' ? 'active' : ''}"
318
+ @click=${() => this.onChartTypeChange('boxplot')}
319
+ >
320
+ 박스플롯
321
+ </button>
322
+ <button
323
+ class="toggle-button ${this.selectedChartType === 'radar' ? 'active' : ''}"
324
+ @click=${() => this.onChartTypeChange('radar')}
325
+ >
326
+ 레이더차트
327
+ </button>
328
+ </div>
329
+ <div class="chart-container">
330
+ ${this.selectedChartType === 'boxplot'
331
+ ? html`
332
+ <kpi-boxplot-chart
333
+ .data=${this.chartData}
334
+ .groups=${this.chartCategories}
335
+ .valueKey=${'value'}
336
+ .currentGroup=${this.selectedCategory}
337
+ ></kpi-boxplot-chart>
338
+ `
339
+ : html`
340
+ <kpi-radar-chart
341
+ .data=${this.chartData}
342
+ .categories=${this.chartCategories}
343
+ .valueKey=${'value'}
344
+ .currentGroup=${this.selectedCategory}
345
+ ></kpi-radar-chart>
346
+ `}
347
+ </div>
348
+ </div>
349
+
350
+ <!-- 시도별 성과 -->
351
+ <div class="chart-section">
352
+ <div class="sub-title">시도별 성과</div>
353
+ <table class="performance-table">
354
+ <thead>
355
+ <tr>
356
+ <th>지역명</th>
357
+ <th>KPI</th>
358
+ <th>변동률(%)</th>
359
+ <th>성과 추이</th>
360
+ </tr>
361
+ </thead>
362
+ <tbody>
363
+ ${this.mapData?.slice(0, 5).map(
364
+ (item: any) => html`
365
+ <tr
366
+ style="cursor: pointer; transition: background-color 0.2s;"
367
+ @click=${() => this.onRegionClick(item.region)}
368
+ @mouseenter=${(e: Event) => ((e.target as HTMLElement).style.backgroundColor = '#f8f9fa')}
369
+ @mouseleave=${(e: Event) => ((e.target as HTMLElement).style.backgroundColor = '')}
370
+ >
371
+ <td>${item.region}</td>
372
+ <td>${item.kpi}</td>
373
+ <td>
374
+ <div class="change-rate ${this.getChangeRateClass(item.change)}">
375
+ ${this.getChangeIcon(item.change)}${Math.abs(item.change)}%
376
+ </div>
377
+ </td>
378
+ <td>
379
+ <kpi-mini-trend-chart
380
+ .data=${this.generateTrendData(item.region)}
381
+ .width=${60}
382
+ .height=${30}
383
+ .lineColor=${'#2196f3'}
384
+ .strokeWidth=${1.5}
385
+ .showPoints=${true}
386
+ .pointRadius=${1.5}
387
+ ></kpi-mini-trend-chart>
388
+ </td>
389
+ </tr>
390
+ `
391
+ )}
392
+ </tbody>
393
+ </table>
394
+ <button class="download-button" @click=${this.downloadExcel}>📊 엑셀 다운로드</button>
395
+ </div>
396
+ </div>
397
+ `
398
+ }
399
+ }
@@ -0,0 +1,302 @@
1
+ import { LitElement, html, css, nothing } from 'lit'
2
+ import { customElement, property, state } from 'lit/decorators.js'
3
+
4
+ import '../../../google-map/common-google-map.js'
5
+
6
+ declare global {
7
+ interface Window {
8
+ google: any
9
+ }
10
+ }
11
+
12
+ @customElement('kpi-map-panel')
13
+ export class KpiMapPanel extends LitElement {
14
+ static styles = css`
15
+ :host {
16
+ display: flex;
17
+ background: #f8f9fa;
18
+ overflow: hidden;
19
+ position: relative;
20
+ min-height: 500px;
21
+ }
22
+ .map-overlay {
23
+ position: absolute;
24
+ top: 16px;
25
+ left: 16px;
26
+ z-index: 10;
27
+ background: rgba(255, 255, 255, 0.95);
28
+ border-radius: 8px;
29
+ padding: 12px 16px;
30
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
31
+ backdrop-filter: blur(4px);
32
+ }
33
+ .category-buttons {
34
+ display: flex;
35
+ gap: 8px;
36
+ flex-wrap: wrap;
37
+ }
38
+ .category-button {
39
+ padding: 6px 12px;
40
+ border: 1px solid #ced4da;
41
+ background: #fff;
42
+ border-radius: 4px;
43
+ cursor: pointer;
44
+ font-size: 0.85rem;
45
+ transition: all 0.2s;
46
+ white-space: nowrap;
47
+ }
48
+ .category-button.active {
49
+ background: #667eea;
50
+ color: white;
51
+ border-color: #667eea;
52
+ }
53
+ .category-button:hover {
54
+ background: #f8f9fa;
55
+ }
56
+ .category-button.active:hover {
57
+ background: #5a6fd8;
58
+ }
59
+ .map-container {
60
+ flex: 1;
61
+ position: relative;
62
+ overflow: hidden;
63
+ }
64
+ .map-controls {
65
+ position: absolute;
66
+ top: 16px;
67
+ right: 16px;
68
+ display: flex;
69
+ flex-direction: column;
70
+ gap: 8px;
71
+ z-index: 10;
72
+ }
73
+ .map-control-button {
74
+ width: 40px;
75
+ height: 40px;
76
+ background: white;
77
+ border: 1px solid #ced4da;
78
+ border-radius: 6px;
79
+ cursor: pointer;
80
+ display: flex;
81
+ align-items: center;
82
+ justify-content: center;
83
+ font-size: 1.2rem;
84
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
85
+ }
86
+ .map-control-button:hover {
87
+ background: #f8f9fa;
88
+ }
89
+ .map-scale-direction {
90
+ position: absolute;
91
+ bottom: 16px;
92
+ right: 16px;
93
+ background: white;
94
+ padding: 8px 12px;
95
+ border-radius: 6px;
96
+ border: 1px solid #ced4da;
97
+ font-size: 0.8rem;
98
+ color: #666;
99
+ z-index: 10;
100
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
101
+ text-align: center;
102
+ }
103
+ .north-arrow {
104
+ font-size: 1rem;
105
+ margin-bottom: 4px;
106
+ }
107
+ .scale-info {
108
+ font-size: 0.7rem;
109
+ }
110
+ common-google-map {
111
+ width: 100%;
112
+ height: 100%;
113
+ }
114
+ `
115
+
116
+ @property({ type: String }) selectedCategory = '전체 KPI'
117
+ @property({ type: Array }) mapData: any[] = []
118
+
119
+ @state() private map: any = null
120
+
121
+ // mapData를 지도 마커 형식으로 변환
122
+ get mapLocations() {
123
+ return (
124
+ this.mapData?.map(item => ({
125
+ lat: item.lat,
126
+ lng: item.lng,
127
+ title: item.region,
128
+ region: item.region, // 지역명 추가
129
+ // 커스텀 마커 콘텐츠 생성
130
+ markerContent: `
131
+ <div style="
132
+ background: white;
133
+ border-radius: 8px;
134
+ padding: 8px 12px;
135
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
136
+ border: 1px solid #e9ecef;
137
+ min-width: 80px;
138
+ text-align: center;
139
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
140
+ cursor: pointer;
141
+ ">
142
+ <div style="
143
+ font-size: 11px;
144
+ font-weight: 600;
145
+ color: #495057;
146
+ margin-bottom: 2px;
147
+ white-space: nowrap;
148
+ ">${item.region}</div>
149
+ <div style="
150
+ font-size: 13px;
151
+ font-weight: 700;
152
+ color: #212529;
153
+ margin-bottom: 2px;
154
+ ">${item.kpi}</div>
155
+ <div style="
156
+ font-size: 10px;
157
+ color: ${item.change > 0 ? '#dc3545' : '#198754'};
158
+ font-weight: 500;
159
+ ">${item.change > 0 ? '▲' : '▼'} ${Math.abs(item.change)}%</div>
160
+ </div>
161
+ `,
162
+ content: `
163
+ <div style="padding: 12px; min-width: 200px;">
164
+ <h3 style="margin: 0 0 8px 0; font-size: 16px; color: #212529;">${item.region}</h3>
165
+ <div style="margin-bottom: 8px;">
166
+ <span style="font-size: 14px; color: #6c757d;">KPI: </span>
167
+ <span style="font-size: 16px; font-weight: 600; color: #212529;">${item.kpi}</span>
168
+ </div>
169
+ <div style="
170
+ font-size: 14px;
171
+ color: ${item.change > 0 ? '#dc3545' : '#198754'};
172
+ font-weight: 500;
173
+ ">
174
+ ${item.change > 0 ? '▲' : '▼'} ${Math.abs(item.change)}% 변화
175
+ </div>
176
+ </div>
177
+ `
178
+ })) || []
179
+ )
180
+ }
181
+
182
+ private onCategoryButtonClick(category: string) {
183
+ this.dispatchEvent(
184
+ new CustomEvent('category-change', {
185
+ detail: { category },
186
+ bubbles: true,
187
+ composed: true
188
+ })
189
+ )
190
+ }
191
+
192
+ private onMapChange(event: CustomEvent) {
193
+ this.map = event.detail
194
+ }
195
+
196
+ private onRegionClick(event: CustomEvent) {
197
+ this.dispatchEvent(
198
+ new CustomEvent('region-click', {
199
+ detail: event.detail,
200
+ bubbles: true,
201
+ composed: true
202
+ })
203
+ )
204
+ }
205
+
206
+ private zoomIn() {
207
+ if (this.map) {
208
+ this.map.setZoom(this.map.getZoom() + 1)
209
+ }
210
+ }
211
+
212
+ private zoomOut() {
213
+ if (this.map) {
214
+ this.map.setZoom(this.map.getZoom() - 1)
215
+ }
216
+ }
217
+
218
+ private resetView() {
219
+ if (this.map) {
220
+ this.map.setCenter({ lat: 36.5, lng: 127.5 })
221
+ this.map.setZoom(7)
222
+ }
223
+ }
224
+
225
+ render() {
226
+ return html`
227
+ <div class="map-overlay">
228
+ <div class="category-buttons">
229
+ <button
230
+ class="category-button ${this.selectedCategory === '전체 KPI' ? 'active' : ''}"
231
+ @click=${() => this.onCategoryButtonClick('전체 KPI')}
232
+ >
233
+ 전체 KPI
234
+ </button>
235
+ <button
236
+ class="category-button ${this.selectedCategory === '일정 성과' ? 'active' : ''}"
237
+ @click=${() => this.onCategoryButtonClick('일정 성과')}
238
+ >
239
+ 일정 성과
240
+ </button>
241
+ <button
242
+ class="category-button ${this.selectedCategory === '비용 성과' ? 'active' : ''}"
243
+ @click=${() => this.onCategoryButtonClick('비용 성과')}
244
+ >
245
+ 비용 성과
246
+ </button>
247
+ <button
248
+ class="category-button ${this.selectedCategory === '품질 성과' ? 'active' : ''}"
249
+ @click=${() => this.onCategoryButtonClick('품질 성과')}
250
+ >
251
+ 품질 성과
252
+ </button>
253
+ <button
254
+ class="category-button ${this.selectedCategory === '안전 성과' ? 'active' : ''}"
255
+ @click=${() => this.onCategoryButtonClick('안전 성과')}
256
+ >
257
+ 안전 성과
258
+ </button>
259
+ <button
260
+ class="category-button ${this.selectedCategory === '환경 성과' ? 'active' : ''}"
261
+ @click=${() => this.onCategoryButtonClick('환경 성과')}
262
+ >
263
+ 환경 성과
264
+ </button>
265
+ </div>
266
+ </div>
267
+
268
+ <div class="map-container">
269
+ <!-- 지도 컨트롤 (오른쪽 상단) -->
270
+ <div class="map-controls">
271
+ <button class="map-control-button" title="확대" @click=${() => this.zoomIn()}>+</button>
272
+ <button class="map-control-button" title="축소" @click=${() => this.zoomOut()}>-</button>
273
+ <button class="map-control-button" title="뷰 초기화" @click=${() => this.resetView()}>⌖</button>
274
+ </div>
275
+
276
+ <!-- 스케일 및 방향 정보 (오른쪽 하단) -->
277
+ <div class="map-scale-direction">
278
+ <div class="north-arrow">↑ N</div>
279
+ <div class="scale-info">25km</div>
280
+ </div>
281
+
282
+ <!-- 공통 Google Maps 컴포넌트 사용 -->
283
+ <common-google-map
284
+ .center=${{ lat: 36.5, lng: 127.5 }}
285
+ .zoom=${7}
286
+ .locations=${this.mapLocations}
287
+ .clusterZoom=${10}
288
+ .controls=${{
289
+ zoomControl: false,
290
+ mapTypeControl: false,
291
+ scaleControl: false,
292
+ streetViewControl: false,
293
+ rotateControl: false,
294
+ fullscreenControl: false
295
+ }}
296
+ @map-change=${this.onMapChange}
297
+ @region-click=${this.onRegionClick}
298
+ ></common-google-map>
299
+ </div>
300
+ `
301
+ }
302
+ }