1+ /**
2+ * Fan Chart JavaScript Library
3+ * Enhanced genealogy fan chart with D3.js
4+ */
5+
6+ class FanChart {
7+ constructor ( containerId , options = { } ) {
8+ this . containerId = containerId ;
9+ this . options = {
10+ width : 800 ,
11+ height : 600 ,
12+ innerRadius : 50 ,
13+ showNames : true ,
14+ showDates : false ,
15+ colorScheme : 'generation' ,
16+ generations : 5 ,
17+ ...options
18+ } ;
19+
20+ this . svg = null ;
21+ this . g = null ;
22+ this . zoom = null ;
23+ this . data = null ;
24+ }
25+
26+ render ( data ) {
27+ this . data = data ;
28+ this . clear ( ) ;
29+ this . createSvg ( ) ;
30+ this . renderChart ( ) ;
31+ }
32+
33+ clear ( ) {
34+ d3 . select ( `#${ this . containerId } ` ) . selectAll ( "*" ) . remove ( ) ;
35+ }
36+
37+ createSvg ( ) {
38+ const container = d3 . select ( `#${ this . containerId } ` ) ;
39+ const containerNode = container . node ( ) ;
40+ const rect = containerNode . getBoundingClientRect ( ) ;
41+
42+ this . options . width = rect . width || this . options . width ;
43+ this . options . height = rect . height || this . options . height ;
44+
45+ const radius = Math . min ( this . options . width , this . options . height ) / 2 - 20 ;
46+
47+ this . svg = container
48+ . append ( "svg" )
49+ . attr ( "width" , this . options . width )
50+ . attr ( "height" , this . options . height ) ;
51+
52+ this . g = this . svg . append ( "g" )
53+ . attr ( "transform" , `translate(${ this . options . width / 2 } ,${ this . options . height / 2 } )` ) ;
54+
55+ // Add zoom behavior
56+ this . zoom = d3 . zoom ( )
57+ . scaleExtent ( [ 0.5 , 3 ] )
58+ . on ( "zoom" , ( event ) => {
59+ this . g . attr ( "transform" ,
60+ `translate(${ this . options . width / 2 } ,${ this . options . height / 2 } ) ${ event . transform } `
61+ ) ;
62+ } ) ;
63+
64+ this . svg . call ( this . zoom ) ;
65+ }
66+
67+ renderChart ( ) {
68+ if ( ! this . data ) return ;
69+
70+ const radius = Math . min ( this . options . width , this . options . height ) / 2 - 20 ;
71+
72+ // Convert data to hierarchical structure
73+ const root = d3 . hierarchy ( this . data ) ;
74+
75+ // Create partition layout
76+ const partition = d3 . partition ( )
77+ . size ( [ 2 * Math . PI , radius ] ) ;
78+
79+ partition ( root ) ;
80+
81+ // Create arc generator
82+ const arc = d3 . arc ( )
83+ . startAngle ( d => d . x0 )
84+ . endAngle ( d => d . x1 )
85+ . innerRadius ( d => Math . max ( this . options . innerRadius , d . y0 ) )
86+ . outerRadius ( d => d . y1 ) ;
87+
88+ // Draw segments
89+ this . g . selectAll ( ".fan-segment" )
90+ . data ( root . descendants ( ) )
91+ . enter ( )
92+ . append ( "path" )
93+ . attr ( "class" , d => `fan-segment ${ this . getSegmentClass ( d ) } ` )
94+ . attr ( "d" , arc )
95+ . style ( "fill" , d => this . getSegmentColor ( d ) )
96+ . style ( "stroke" , "#fff" )
97+ . style ( "stroke-width" , 1 )
98+ . style ( "cursor" , "pointer" )
99+ . on ( "click" , ( event , d ) => this . onSegmentClick ( event , d ) )
100+ . on ( "mouseover" , ( event , d ) => this . showTooltip ( event , d ) )
101+ . on ( "mouseout" , ( ) => this . hideTooltip ( ) ) ;
102+
103+ // Add text labels
104+ this . addTextLabels ( root . descendants ( ) . filter ( d => d . depth > 0 ) ) ;
105+ }
106+
107+ addTextLabels ( nodes ) {
108+ if ( ! this . options . showNames && ! this . options . showDates ) return ;
109+
110+ const textGroups = this . g . selectAll ( ".fan-text-group" )
111+ . data ( nodes )
112+ . enter ( )
113+ . append ( "g" )
114+ . attr ( "class" , "fan-text-group" ) ;
115+
116+ textGroups . each ( ( d , i , nodes ) => {
117+ const textGroup = d3 . select ( nodes [ i ] ) ;
118+ const angle = ( d . x0 + d . x1 ) / 2 ;
119+ const radius = ( d . y0 + d . y1 ) / 2 ;
120+ const x = Math . sin ( angle ) * radius ;
121+ const y = - Math . cos ( angle ) * radius ;
122+
123+ textGroup . attr ( "transform" , `translate(${ x } ,${ y } ) rotate(${ angle * 180 / Math . PI - 90 } )` ) ;
124+
125+ if ( this . options . showNames && d . data . name ) {
126+ this . addNameText ( textGroup , d ) ;
127+ }
128+
129+ if ( this . options . showDates ) {
130+ this . addDateText ( textGroup , d ) ;
131+ }
132+ } ) ;
133+ }
134+
135+ addNameText ( textGroup , d ) {
136+ const nameText = textGroup . append ( "text" )
137+ . attr ( "class" , "fan-text name" )
138+ . attr ( "text-anchor" , "middle" )
139+ . attr ( "dy" , this . options . showDates ? "-0.2em" : "0.3em" )
140+ . style ( "font-size" , "11px" )
141+ . style ( "font-weight" , "600" )
142+ . style ( "fill" , "#1f2937" ) ;
143+
144+ const name = d . data . name ;
145+ if ( name . length > 15 ) {
146+ const parts = name . split ( ' ' ) ;
147+ if ( parts . length > 1 ) {
148+ nameText . append ( "tspan" )
149+ . attr ( "x" , 0 )
150+ . text ( parts [ 0 ] ) ;
151+ nameText . append ( "tspan" )
152+ . attr ( "x" , 0 )
153+ . attr ( "dy" , "1em" )
154+ . text ( parts . slice ( 1 ) . join ( ' ' ) ) ;
155+ } else {
156+ nameText . text ( name . substring ( 0 , 12 ) + '...' ) ;
157+ }
158+ } else {
159+ nameText . text ( name ) ;
160+ }
161+ }
162+
163+ addDateText ( textGroup , d ) {
164+ const birthYear = d . data . birth_year || '?' ;
165+ const deathYear = d . data . death_year || '' ;
166+ const dateText = `${ birthYear } ${ deathYear ? '-' + deathYear : '' } ` ;
167+
168+ textGroup . append ( "text" )
169+ . attr ( "class" , "fan-text dates" )
170+ . attr ( "text-anchor" , "middle" )
171+ . attr ( "dy" , this . options . showNames ? "1em" : "0.3em" )
172+ . style ( "font-size" , "9px" )
173+ . style ( "fill" , "#6b7280" )
174+ . text ( dateText ) ;
175+ }
176+
177+ getSegmentClass ( d ) {
178+ return `generation-${ d . depth } ` ;
179+ }
180+
181+ getSegmentColor ( d ) {
182+ switch ( this . options . colorScheme ) {
183+ case 'generation' :
184+ const colors = [ '#10b981' , '#3b82f6' , '#8b5cf6' , '#f59e0b' , '#ef4444' , '#ec4899' , '#06b6d4' , '#84cc16' ] ;
185+ return colors [ d . depth % colors . length ] ;
186+
187+ case 'gender' :
188+ const sex = d . data . sex ?. toLowerCase ( ) ;
189+ return sex === 'm' ? '#3b82f6' : sex === 'f' ? '#ec4899' : '#6b7280' ;
190+
191+ case 'branch' :
192+ if ( d . depth === 0 ) return '#10b981' ;
193+ let current = d ;
194+ while ( current . parent && current . parent . depth > 0 ) {
195+ current = current . parent ;
196+ }
197+ const isPaternal = current . parent && current . parent . children . indexOf ( current ) === 0 ;
198+ return isPaternal ? '#3b82f6' : '#ec4899' ;
199+
200+ default :
201+ return '#3b82f6' ;
202+ }
203+ }
204+
205+ onSegmentClick ( event , d ) {
206+ if ( this . options . onPersonClick && d . data . id ) {
207+ this . options . onPersonClick ( d . data . id ) ;
208+ }
209+ }
210+
211+ showTooltip ( event , d ) {
212+ const tooltip = d3 . select ( "body" ) . append ( "div" )
213+ . attr ( "class" , "fan-tooltip" )
214+ . style ( "position" , "absolute" )
215+ . style ( "background" , "rgba(0,0,0,0.8)" )
216+ . style ( "color" , "white" )
217+ . style ( "padding" , "8px" )
218+ . style ( "border-radius" , "4px" )
219+ . style ( "font-size" , "12px" )
220+ . style ( "pointer-events" , "none" )
221+ . style ( "z-index" , "1000" ) ;
222+
223+ let content = `<strong>${ d . data . name || 'Unknown' } </strong>` ;
224+ if ( d . data . birth_year || d . data . death_year ) {
225+ content += `<br>${ d . data . birth_year || '?' } - ${ d . data . death_year || '' } ` ;
226+ }
227+ content += `<br>Generation: ${ d . depth } ` ;
228+ content += `<br>Click to expand` ;
229+
230+ tooltip . html ( content )
231+ . style ( "left" , ( event . pageX + 10 ) + "px" )
232+ . style ( "top" , ( event . pageY - 10 ) + "px" ) ;
233+ }
234+
235+ hideTooltip ( ) {
236+ d3 . selectAll ( ".fan-tooltip" ) . remove ( ) ;
237+ }
238+
239+ zoomIn ( ) {
240+ this . svg . transition ( ) . call ( this . zoom . scaleBy , 1.5 ) ;
241+ }
242+
243+ zoomOut ( ) {
244+ this . svg . transition ( ) . call ( this . zoom . scaleBy , 1 / 1.5 ) ;
245+ }
246+
247+ resetZoom ( ) {
248+ this . svg . transition ( ) . call ( this . zoom . transform , d3 . zoomIdentity ) ;
249+ }
250+
251+ updateOptions ( newOptions ) {
252+ this . options = { ...this . options , ...newOptions } ;
253+ if ( this . data ) {
254+ this . render ( this . data ) ;
255+ }
256+ }
257+ }
258+
259+ // Global functions for backward compatibility
260+ function initializeFanChart ( data , options = { } ) {
261+ const chart = new FanChart ( 'fanChartContainer' , options ) ;
262+ chart . render ( data ) ;
263+ return chart ;
264+ }
265+
266+ // Export for module systems
267+ if ( typeof module !== 'undefined' && module . exports ) {
268+ module . exports = FanChart ;
269+ }
0 commit comments