import { LitElement, html, css } from 'lit' import { customElement, property } from 'lit/decorators.js' import * as d3 from 'd3' @customElement('kpi-radar-chart') export class KpiRadarChart extends LitElement { @property({ type: Array }) data: any[] = [] @property({ type: Array }) categories: string[] = [] @property({ type: String }) valueKey: string = 'value' @property({ type: String }) currentGroup: string = '' 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.drawRadar() } drawRadar() { const svg = d3.select(this.renderRoot.querySelector('#radar')) svg.selectAll('*').remove() const w = this.chartWidth || 300 const h = this.chartHeight || 300 const r = Math.min(w, h) / 2 - 40 const angleSlice = (2 * Math.PI) / (this.categories.length || 1) // 데이터 변환: { org, values: [ {category, value} ... ] } const orgData = d3 .groups(this.data, d => d.org) .map(([org, values]) => ({ org, values: this.categories.map((cat, i) => { const found = values.find(v => v.category === cat) return { category: cat, value: found ? found[this.valueKey] : 0 } }) })) // 스케일 const maxValue = d3.max(this.data, d => d[this.valueKey]) || 1 const radius = d3.scaleLinear().domain([0, maxValue]).range([0, r]) // SVG 기본 svg.attr('width', w).attr('height', h) const g = svg.append('g').attr('transform', `translate(${w / 2},${h / 2})`) // 그리드/축 for (let i = 1; i <= 5; i++) { g.append('circle') .attr('r', (r / 5) * i) .attr('fill', 'none') .attr('stroke', '#ccc') } this.categories.forEach((cat, i) => { const angle = i * angleSlice - Math.PI / 2 g.append('line') .attr('x1', 0) .attr('y1', 0) .attr('x2', radius(maxValue) * Math.cos(angle)) .attr('y2', radius(maxValue) * Math.sin(angle)) .attr('stroke', '#ccc') g.append('text') .attr('x', (radius(maxValue) + 10) * Math.cos(angle)) .attr('y', (radius(maxValue) + 10) * Math.sin(angle)) .attr('text-anchor', 'middle') .attr('alignment-baseline', 'middle') .attr('font-size', 12) .text(cat) }) // 그룹별 폴리곤 orgData.forEach(gd => { // 마지막에 첫 점을 한 번 더 추가 const closedValues = [...gd.values, gd.values[0]] const line = d3 .lineRadial() .radius((d: any) => radius(d.value)) .angle((d, i) => i * angleSlice) g.append('path') .datum(closedValues) .attr('d', line as any) .attr('fill', gd.org === this.currentGroup ? 'rgba(33,150,243,0.4)' : 'rgba(200,200,200,0.2)') .attr('stroke', gd.org === this.currentGroup ? '#2196f3' : '#aaa') .attr('stroke-width', gd.org === this.currentGroup ? 3 : 1) }) } }