import { LitElement, html, css } from 'lit' import { customElement, property, state } from 'lit/decorators.js' import { ScrollbarStyles } from '@operato/styles' import '../../../charts/kpi-radar-chart.js' import '../../../charts/kpi-boxplot-chart.js' import '../../../charts/kpi-mini-trend-chart.js' @customElement('kpi-left-panel') export class KpiLeftPanel extends LitElement { static styles = [ ScrollbarStyles, css` :host { display: block; width: 400px; background: #fff; border-right: 1px solid #e0e0e0; overflow: hidden; display: flex; flex-direction: column; box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1); } .panel-content { padding: 20px; overflow-y: auto; flex: 1; } .panel-header { display: flex; justify-content: space-between; align-items: center; padding: 20px; border-bottom: 1px solid #e0e0e0; background: #fff; height: 70px; box-sizing: border-box; } .panel-title { font-size: 1.3rem; font-weight: bold; color: #333; margin: 0; } .panel-close { width: 32px; height: 32px; border: none; background: #fff; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; color: #666; transition: all 0.2s; } .panel-close:hover { background: #e9ecef; color: #333; } .sub-title { font-size: 1rem; font-weight: 600; margin-bottom: 16px; color: #495057; } .chart-section { background: #f8f9fa; border-radius: 8px; padding: 16px; margin-bottom: 20px; } .chart-toggle { display: flex; gap: 8px; margin-bottom: 16px; } .toggle-button { padding: 8px 16px; border: 1px solid #ced4da; background: #fff; border-radius: 6px; cursor: pointer; font-size: 0.9rem; transition: all 0.2s; } .toggle-button.active { background: #667eea; color: white; border-color: #667eea; } .chart-container { height: 300px; display: flex; align-items: center; justify-content: center; background: white; border-radius: 6px; border: 1px solid #e9ecef; } .performance-table { width: 100%; border-collapse: collapse; margin-top: 16px; } .performance-table th, .performance-table td { padding: 12px 8px; text-align: left; border-bottom: 1px solid #e9ecef; } .performance-table th { background: #f8f9fa; font-weight: 600; color: #495057; } .performance-table td { color: #333; } .change-rate { display: flex; align-items: center; gap: 4px; } .change-up { color: #dc3545; } .change-down { color: #198754; } .change-neutral { color: #6c757d; } .trend-chart { width: 60px; height: 30px; background: #f8f9fa; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 0.8rem; color: #666; } .download-button { margin-top: 16px; padding: 8px 16px; background: #28a745; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.9rem; display: flex; align-items: center; gap: 8px; } .download-button:hover { background: #218838; } .category-select { width: 100%; padding: 8px 12px; border: 1px solid #ced4da; border-radius: 6px; background: white; margin-bottom: 20px; } ` ] @property({ type: String }) selectedCategory = '전체 KPI' @property({ type: String }) selectedChartType = 'boxplot' @property({ type: Array }) mapData: any[] = [] @state() private chartData: any[] = [] @state() private chartCategories: string[] = [] // 행정안전부 행정구역코드 순서 private readonly regionOrder = [ '서울특별시', '부산광역시', '대구광역시', '인천광역시', '광주광역시', '대전광역시', '울산광역시', '세종특별자치시', '경기도', '강원도', '충청북도', '충청남도', '전라북도', '전라남도', '경상북도', '경상남도', '제주특별자치도' ] connectedCallback() { super.connectedCallback() this.generateChartData() } private generateTrendData(region: string): number[] { // 각 지역별로 7일간의 트렌드 데이터 생성 const baseValue = Math.random() * 30 + 60 // 60-90 범위 const trendData: number[] = [] for (let i = 0; i < 7; i++) { const trend = Math.sin(i * 0.5) * 5 // 사인파로 변동 const noise = (Math.random() - 0.5) * 3 // 랜덤 노이즈 const value = Math.max(0, Math.min(100, baseValue + trend + noise)) trendData.push(Math.round(value)) } return trendData } private generateChartData() { // 선택된 카테고리에 따른 차트 데이터 생성 const categories = ['일정 성과', '비용 성과', '품질 성과', '안전 성과', '환경 성과'] this.chartCategories = categories if (this.selectedChartType === 'radar') { // 레이더 차트용 데이터 생성 this.chartData = categories.map(category => ({ category, value: Math.random() * 50 + 25, // 25-75 범위 group: this.selectedCategory })) } else { // 박스플롯용 데이터 생성 this.chartData = categories.map(category => { const baseValue = Math.random() * 50 + 25 // 25-75 범위 const variation = Math.random() * 20 // 변동폭 // 각 카테고리별로 20개의 데이터 포인트 생성 const dataPoints: { value: number; group: string }[] = [] for (let i = 0; i < 20; i++) { dataPoints.push({ value: baseValue + (Math.random() - 0.5) * variation, group: category }) } // 통계값 계산 const values = dataPoints.map(d => d.value).sort((a, b) => a - b) const min = Math.min(...values) const max = Math.max(...values) const q1 = values[Math.floor(values.length * 0.25)] const q3 = values[Math.floor(values.length * 0.75)] const median = values[Math.floor(values.length * 0.5)] const mean = values.reduce((a, b) => a + b, 0) / values.length return { group: category, min: min, max: max, q1: q1, q3: q3, median: median, mean: mean, value: mean } }) } } private onCategoryChange(event: Event) { const target = event.target as HTMLSelectElement this.dispatchEvent( new CustomEvent('category-change', { detail: { category: target.value }, bubbles: true, composed: true }) ) this.generateChartData() } private onChartTypeChange(type: string) { this.selectedChartType = type this.generateChartData() } private onRegionClick(region: string) { this.dispatchEvent( new CustomEvent('region-click', { detail: { region }, bubbles: true, composed: true }) ) } private downloadExcel() { this.dispatchEvent( new CustomEvent('download-excel', { bubbles: true, composed: true }) ) } // regionOrder에 따라 지역 데이터를 정렬 private getSortedRegionData() { // 모든 시도에 대한 데이터 생성 return this.regionOrder.map(regionName => ({ region: regionName, kpi: (Math.random() * 30 + 70).toFixed(1), // 70-100 범위 change: (Math.random() * 0.4 - 0.2).toFixed(2), // -0.2 ~ 0.2 범위 lat: 36.5, lng: 127.5 })) } private getChangeRateClass(change: number): string { if (change > 0) return 'change-up' if (change < 0) return 'change-down' return 'change-neutral' } private getChangeIcon(change: number): string { if (change > 0) return '▲' if (change < 0) return '▼' return '─' } render() { return html`
| 지역명 | KPI | 변동률(%) | 성과 추이 |
|---|---|---|---|
| ${item.region} | ${item.kpi} |
${this.getChangeIcon(item.change)}${Math.abs(item.change)}%
|
|