import { LitElement, html, css } from 'lit' import { customElement, property } from 'lit/decorators.js' import * as d3 from 'd3' @customElement('kpi-boxplot-chart') export class KpiBoxplotChart extends LitElement { @property({ type: Array }) data: any[] = [] @property({ type: Array }) groups: string[] = [] // 조직 단위 목록 (빈 배열이면 데이터에서 자동 추출) @property({ type: String }) minKey: string = 'min' @property({ type: String }) maxKey: string = 'max' @property({ type: String }) meanKey: string = 'mean' @property({ type: String }) medianKey: string = 'median' @property({ type: String }) q1Key: string = 'q1' @property({ type: String }) q3Key: string = 'q3' @property({ type: String }) valueKey: string = 'value' @property({ type: String }) currentGroup: string = '' @property({ type: Boolean }) independentScale: boolean = false static styles = css` :host { display: block; width: 100%; height: 100%; } svg { width: 100%; height: 100%; display: block; } ` private chartWidth = 0 private chartHeight = 0 private resizeObserver?: ResizeObserver render() { return html`` } connectedCallback() { super.connectedCallback() this.resizeObserver = new ResizeObserver(entries => { for (const entry of entries) { const rect = entry.contentRect this.chartWidth = rect.width this.chartHeight = rect.height this.requestUpdate() } }) this.resizeObserver.observe(this) } disconnectedCallback() { this.resizeObserver?.disconnect() super.disconnectedCallback() } updated() { this.drawBoxplot() } private getOrgValue = (d: any) => d.org || d.group drawBoxplot() { const svg = d3.select(this.renderRoot.querySelector('#boxplot')) svg.selectAll('*').remove() // 데이터 검증 if (!this.data || this.data.length === 0) { return } const w = this.chartWidth || 300 const h = this.chartHeight || 300 const margin = { top: 20, right: 20, bottom: 40, left: this.independentScale ? 20 : 40 } const plotW = w - margin.left - margin.right const plotH = h - margin.top - margin.bottom // x축: 조직 단위 (그룹) - org 또는 group 필드 지원 const groups = this.groups.length > 0 ? this.groups : [...new Set(this.data.map(d => this.getOrgValue(d)).filter(org => org != null))] if (groups.length === 0) { console.warn('No valid groups found in data:', this.data) return // 유효한 그룹이 없으면 차트를 그리지 않음 } console.log('Boxplot groups:', groups) console.log('Data org values:', this.data.map(d => this.getOrgValue(d))) console.log('Full data:', this.data) console.log('Chart dimensions:', { w, h, plotW, plotH }) const x = d3.scaleBand().domain(groups).range([0, plotW]).padding(0.4) // y축 스케일 설정 let y: d3.ScaleLinear if (this.independentScale) { // 독립 스케일: 각 시리즈별로 개별 스케일 생성 const yScales = this.data.map(d => { const values = [ d[this.minKey], d[this.maxKey], d[this.q1Key], d[this.q3Key], d[this.medianKey], d[this.meanKey], d[this.valueKey] ] const min = d3.min(values) ?? 0 const max = d3.max(values) ?? 1 return { org: d.org, scale: d3.scaleLinear().domain([min, max]).nice().range([plotH, 0]) } }) // 기본 y축은 첫 번째 스케일 사용 y = yScales[0]?.scale || d3.scaleLinear().domain([0, 1]).range([plotH, 0]) } else { // 통합 스케일: 모든 데이터를 하나의 스케일에 맞춤 const allValues = this.data.flatMap(d => [ d[this.minKey], d[this.maxKey], d[this.q1Key], d[this.q3Key], d[this.medianKey], d[this.meanKey], d[this.valueKey] ]) y = d3 .scaleLinear() .domain([d3.min(allValues) ?? 0, d3.max(allValues) ?? 1]) .nice() .range([plotH, 0]) } const g = svg .attr('width', w) .attr('height', h) .append('g') .attr('transform', `translate(${margin.left},${margin.top})`) // 축 if (!this.independentScale) { g.append('g').call(d3.axisLeft(y)) } g.append('g').attr('transform', `translate(0,${plotH})`).call(d3.axisBottom(x)) // 박스플롯 - 각 그룹별로 박스 생성 groups.forEach(groupName => { // 해당 그룹의 데이터 찾기 const groupData = this.data.find(d => this.getOrgValue(d) === groupName) console.log(`Searching for group: ${groupName}, found:`, groupData) if (!groupData) { console.warn(`No data found for group: ${groupName}. Available data:`, this.data.map(d => ({ org: d.org, keys: Object.keys(d) }))) return } // 필수 필드 검증 const requiredFields = [this.minKey, this.maxKey, this.q1Key, this.q3Key, this.medianKey, this.meanKey] const missingFields = requiredFields.filter(field => groupData[field] == null) if (missingFields.length > 0) { console.warn(`Missing required fields for group ${groupName}:`, missingFields) console.log('Group data:', groupData) return } const gx = x(groupName) ?? 0 console.log(`Drawing box for ${groupName} at x=${gx}, bandwidth=${x.bandwidth()}`) // 독립 스케일 사용 시 해당 그룹의 스케일 찾기 let currentY = y if (this.independentScale) { const values = [ groupData[this.minKey], groupData[this.maxKey], groupData[this.q1Key], groupData[this.q3Key], groupData[this.medianKey], groupData[this.meanKey], groupData[this.valueKey] ] const min = d3.min(values) ?? 0 const max = d3.max(values) ?? 1 currentY = d3.scaleLinear().domain([min, max]).nice().range([plotH, 0]) } // Outlier 계산 (1.5 * IQR 규칙) const iqr = groupData[this.q3Key] - groupData[this.q1Key] const lowerFence = groupData[this.q1Key] - 1.5 * iqr const upperFence = groupData[this.q3Key] + 1.5 * iqr // 실제 min/max 값 (fence 내부) const actualMin = Math.max(groupData[this.minKey], lowerFence) const actualMax = Math.min(groupData[this.maxKey], upperFence) // 박스 g.append('rect') .attr('x', gx) .attr('y', currentY(groupData[this.q3Key])) .attr('width', x.bandwidth()) .attr('height', currentY(groupData[this.q1Key]) - currentY(groupData[this.q3Key])) .attr('fill', this.getOrgValue(groupData) === this.currentGroup ? '#2196f3' : '#bbb') .attr('opacity', 0.5) // 중앙선(중앙값) g.append('line') .attr('x1', gx) .attr('x2', gx + x.bandwidth()) .attr('y1', currentY(groupData[this.medianKey])) .attr('y2', currentY(groupData[this.medianKey])) .attr('stroke', '#333') .attr('stroke-width', 2) // 수염 (fence 내부의 min-max) g.append('line') .attr('x1', gx + x.bandwidth() / 2) .attr('x2', gx + x.bandwidth() / 2) .attr('y1', currentY(actualMin)) .attr('y2', currentY(actualMax)) .attr('stroke', '#333') // min/max 선 (fence 내부) g.append('line') .attr('x1', gx + x.bandwidth() / 4) .attr('x2', gx + (x.bandwidth() * 3) / 4) .attr('y1', currentY(actualMin)) .attr('y2', currentY(actualMin)) .attr('stroke', '#333') g.append('line') .attr('x1', gx + x.bandwidth() / 4) .attr('x2', gx + (x.bandwidth() * 3) / 4) .attr('y1', currentY(actualMax)) .attr('y2', currentY(actualMax)) .attr('stroke', '#333') // Outlier 표시 (fence 외부의 값들) if (groupData[this.minKey] < lowerFence) { g.append('circle') .attr('cx', gx + x.bandwidth() / 2) .attr('cy', currentY(groupData[this.minKey])) .attr('r', 3) .attr('fill', '#ff4444') .attr('stroke', '#333') .attr('stroke-width', 1) } if (groupData[this.maxKey] > upperFence) { g.append('circle') .attr('cx', gx + x.bandwidth() / 2) .attr('cy', currentY(groupData[this.maxKey])) .attr('r', 3) .attr('fill', '#ff4444') .attr('stroke', '#333') .attr('stroke-width', 1) } // 평균값 g.append('circle') .attr('cx', gx + x.bandwidth() / 2) .attr('cy', currentY(groupData[this.meanKey])) .attr('r', 4) .attr('fill', 'orange') }) // 현재 그룹 값 강조 if (this.currentGroup && groups.includes(this.currentGroup)) { const currentGroupData = this.data.find(d => this.getOrgValue(d) === this.currentGroup) if (currentGroupData) { let currentY = y if (this.independentScale) { const values = [ currentGroupData[this.minKey], currentGroupData[this.maxKey], currentGroupData[this.q1Key], currentGroupData[this.q3Key], currentGroupData[this.medianKey], currentGroupData[this.meanKey], currentGroupData[this.valueKey] ] const min = d3.min(values) ?? 0 const max = d3.max(values) ?? 1 currentY = d3.scaleLinear().domain([min, max]).nice().range([plotH, 0]) } g.append('circle') .attr('cx', x(this.currentGroup) + x.bandwidth() / 2) .attr('cy', currentY(currentGroupData[this.valueKey])) .attr('r', 6) .attr('fill', '#e91e63') .attr('stroke', '#fff') .attr('stroke-width', 2) } } } }