1+ import { useEffect , useRef } from 'react' ;
2+ import * as d3 from 'd3' ;
3+
4+ interface RadarDataPoint {
5+ axis : string ;
6+ value : number ;
7+ maxValue : number ;
8+ color ?: string ;
9+ }
10+
11+ interface RadarChartProps {
12+ data : RadarDataPoint [ ] ;
13+ width ?: number ;
14+ height ?: number ;
15+ margin ?: number ;
16+ levels ?: number ;
17+ className ?: string ;
18+ }
19+
20+ export function RadarChart ( {
21+ data,
22+ width = 300 ,
23+ height = 300 ,
24+ margin = 50 ,
25+ levels = 5 ,
26+ className = ''
27+ } : RadarChartProps ) {
28+ const svgRef = useRef < SVGSVGElement > ( null ) ;
29+
30+ useEffect ( ( ) => {
31+ if ( ! svgRef . current || ! data . length ) return ;
32+
33+ const svg = d3 . select ( svgRef . current ) ;
34+ svg . selectAll ( '*' ) . remove ( ) ;
35+
36+ const radius = Math . min ( width - 2 * margin , height - 2 * margin ) / 2 ;
37+ const centerX = width / 2 ;
38+ const centerY = height / 2 ;
39+
40+ const angleSlice = ( Math . PI * 2 ) / data . length ;
41+
42+ // Create the container group
43+ const g = svg . append ( 'g' )
44+ . attr ( 'transform' , `translate(${ centerX } ,${ centerY } )` ) ;
45+
46+ // Create the circular grid lines
47+ const levelFactor = radius / levels ;
48+
49+ for ( let level = 1 ; level <= levels ; level ++ ) {
50+ g . append ( 'circle' )
51+ . attr ( 'r' , levelFactor * level )
52+ . attr ( 'fill' , 'none' )
53+ . attr ( 'stroke' , 'white' )
54+ . attr ( 'stroke-width' , 1 )
55+ . attr ( 'opacity' , 0.2 ) ;
56+ }
57+
58+ // Create the axis lines
59+ data . forEach ( ( d , i ) => {
60+ const angle = i * angleSlice ;
61+ const x = Math . cos ( angle - Math . PI / 2 ) * radius ;
62+ const y = Math . sin ( angle - Math . PI / 2 ) * radius ;
63+
64+ g . append ( 'line' )
65+ . attr ( 'x1' , 0 )
66+ . attr ( 'y1' , 0 )
67+ . attr ( 'x2' , x )
68+ . attr ( 'y2' , y )
69+ . attr ( 'stroke' , 'white' )
70+ . attr ( 'stroke-width' , 1 )
71+ . attr ( 'opacity' , 0.3 ) ;
72+
73+ // Add axis labels with rounded rectangle background
74+ const labelX = Math . cos ( angle - Math . PI / 2 ) * ( radius + 30 ) ;
75+ const labelY = Math . sin ( angle - Math . PI / 2 ) * ( radius + 30 ) ;
76+
77+ // Create the label text first to measure its size
78+ const labelText = `${ d . axis } (${ d . value } )` ;
79+ const tempText = g . append ( 'text' )
80+ . attr ( 'x' , labelX )
81+ . attr ( 'y' , labelY )
82+ . attr ( 'text-anchor' , 'middle' )
83+ . attr ( 'dominant-baseline' , 'middle' )
84+ . attr ( 'font-size' , '13px' )
85+ . attr ( 'font-weight' , '500' )
86+ . attr ( 'opacity' , 0 )
87+ . text ( labelText ) ;
88+
89+ // Get text dimensions
90+ const textBBox = ( tempText . node ( ) as SVGTextElement ) . getBBox ( ) ;
91+ tempText . remove ( ) ;
92+
93+ // Add rounded rectangle background
94+ const padding = 6 ;
95+ g . append ( 'rect' )
96+ . attr ( 'x' , labelX - textBBox . width / 2 - padding )
97+ . attr ( 'y' , labelY - textBBox . height / 2 - padding )
98+ . attr ( 'width' , textBBox . width + padding * 2 )
99+ . attr ( 'height' , textBBox . height + padding * 2 )
100+ . attr ( 'rx' , 6 )
101+ . attr ( 'ry' , 6 )
102+ . attr ( 'fill' , 'rgba(31, 41, 55, 0.9)' )
103+ . attr ( 'stroke' , d . color || 'rgb(99, 102, 241)' )
104+ . attr ( 'stroke-width' , 1 ) ;
105+
106+ // Add the actual text
107+ g . append ( 'text' )
108+ . attr ( 'x' , labelX )
109+ . attr ( 'y' , labelY )
110+ . attr ( 'text-anchor' , 'middle' )
111+ . attr ( 'dominant-baseline' , 'middle' )
112+ . attr ( 'font-size' , '13px' )
113+ . attr ( 'font-weight' , '500' )
114+ . attr ( 'fill' , 'white' )
115+ . text ( labelText ) ;
116+ } ) ;
117+
118+ // Create the radar area
119+ const radarLine = d3 . line < RadarDataPoint > ( )
120+ . x ( ( d , i ) => {
121+ const angle = i * angleSlice ;
122+ const value = Math . max ( 0 , d . value ) ;
123+ const normalizedValue = d . maxValue > 0 ? value / d . maxValue : 0 ;
124+ return Math . cos ( angle - Math . PI / 2 ) * ( normalizedValue * radius ) ;
125+ } )
126+ . y ( ( d , i ) => {
127+ const angle = i * angleSlice ;
128+ const value = Math . max ( 0 , d . value ) ;
129+ const normalizedValue = d . maxValue > 0 ? value / d . maxValue : 0 ;
130+ return Math . sin ( angle - Math . PI / 2 ) * ( normalizedValue * radius ) ;
131+ } )
132+ . curve ( d3 . curveLinearClosed ) ;
133+
134+ // Add the radar area
135+ g . append ( 'path' )
136+ . datum ( data )
137+ . attr ( 'd' , radarLine )
138+ . attr ( 'fill' , 'rgba(156, 163, 175, 0.1)' )
139+ . attr ( 'stroke' , 'rgb(156, 163, 175)' )
140+ . attr ( 'stroke-width' , 2 ) ;
141+
142+ // Add data points with status-based colors
143+ data . forEach ( ( d , i ) => {
144+ const angle = i * angleSlice ;
145+ const value = Math . max ( 0 , d . value ) ;
146+ const normalizedValue = d . maxValue > 0 ? value / d . maxValue : 0 ;
147+ const x = Math . cos ( angle - Math . PI / 2 ) * ( normalizedValue * radius ) ;
148+ const y = Math . sin ( angle - Math . PI / 2 ) * ( normalizedValue * radius ) ;
149+
150+ const pointColor = d . color || 'rgb(99, 102, 241)' ;
151+ const strokeColor = d3 . color ( pointColor ) ?. darker ( 0.3 ) ?. toString ( ) || pointColor ;
152+
153+ g . append ( 'circle' )
154+ . attr ( 'cx' , x )
155+ . attr ( 'cy' , y )
156+ . attr ( 'r' , 6 )
157+ . attr ( 'fill' , pointColor )
158+ . attr ( 'stroke' , strokeColor )
159+ . attr ( 'stroke-width' , 2 ) ;
160+ } ) ;
161+
162+ // Add level labels with actual numbers
163+ for ( let level = 1 ; level <= levels ; level ++ ) {
164+ const maxDataValue = Math . max ( ...data . map ( d => d . maxValue ) , 1 ) ;
165+ const value = Math . round ( ( level / levels ) * maxDataValue ) ;
166+ g . append ( 'text' )
167+ . attr ( 'x' , 5 )
168+ . attr ( 'y' , - ( levelFactor * level ) + 3 )
169+ . attr ( 'font-size' , '11px' )
170+ . attr ( 'font-weight' , '400' )
171+ . attr ( 'fill' , 'white' )
172+ . attr ( 'opacity' , 0.7 )
173+ . text ( `${ value } ` ) ;
174+ }
175+
176+ } , [ data , width , height , margin , levels ] ) ;
177+
178+ return (
179+ < div className = { className } >
180+ < svg
181+ ref = { svgRef }
182+ width = { width }
183+ height = { height }
184+ style = { { background : 'transparent' } }
185+ />
186+ </ div >
187+ ) ;
188+ }
0 commit comments