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)
}
}
}
}