@@ -18,6 +18,22 @@ interface BubbleNode extends d3.SimulationNodeDatum {
1818 name : string
1919 value : number
2020 r : number
21+ colorIdx : number
22+ __restX ?: number
23+ __restY ?: number
24+ }
25+
26+ const PALETTE = [
27+ '#a78bfa' , '#22d3ee' , '#fb923c' , '#f87171' , '#4ade80' ,
28+ '#facc15' , '#60a5fa' , '#f472b6' , '#a3e635' , '#34d399' ,
29+ '#e879f9' , '#38bdf8' , '#fbbf24' , '#fb7185' , '#c084fc' ,
30+ ]
31+
32+ function hexToRgba ( hex : string , a : number ) : string {
33+ const r = parseInt ( hex . slice ( 1 , 3 ) , 16 )
34+ const g = parseInt ( hex . slice ( 3 , 5 ) , 16 )
35+ const b = parseInt ( hex . slice ( 5 , 7 ) , 16 )
36+ return `rgba(${ r } ,${ g } ,${ b } ,${ a } )`
2137}
2238
2339const YEAR_MIN = 1920
@@ -34,7 +50,7 @@ const Viz2: React.FC = () => {
3450
3551 const [ data , setData ] = useState < AirlinePoint [ ] > ( [ ] )
3652 const [ metric , setMetric ] = useState < Metric > ( 'incidents' )
37- const [ year , setYear ] = useState ( 2000 )
53+ const [ year , setYear ] = useState ( 1949 )
3854 const [ playing , setPlaying ] = useState ( false )
3955 const [ chartSize , setChartSize ] = useState ( { w : 0 , h : 0 } )
4056
@@ -128,28 +144,25 @@ const Viz2: React.FC = () => {
128144
129145 /* merge with existing nodes to preserve positions */
130146 const existing = new Map ( nodesRef . current . map ( n => [ n . key , n ] ) )
131- const newNodes : BubbleNode [ ] = yearData . map ( ( d : AirlinePoint ) => {
147+ const newNodes : BubbleNode [ ] = yearData . map ( ( d : AirlinePoint , i : number ) => {
132148 const prev = existing . get ( d . key )
133149 const r = Math . max ( rScale ( d [ metric ] ) , 6 )
134150 if ( prev ) {
135- prev . value = d [ metric ]
136- prev . name = d . name
137- prev . r = r
151+ prev . value = d [ metric ]
152+ prev . name = d . name
153+ prev . r = r
154+ prev . colorIdx = i
138155 return prev
139156 }
140157 return {
141- key : d . key , name : d . name , value : d [ metric ] , r,
158+ key : d . key , name : d . name , value : d [ metric ] , r, colorIdx : i ,
142159 x : w / 2 + ( Math . random ( ) - 0.5 ) * w * 0.3 ,
143160 y : h / 2 + ( Math . random ( ) - 0.5 ) * h * 0.3 ,
144161 }
145162 } )
146163 nodesRef . current = newNodes
147164
148- const accent = getComputedStyle ( document . documentElement )
149- . getPropertyValue ( '--accent' ) . trim ( ) || 'rgb(0,234,255)'
150- const fill = accent . startsWith ( 'rgb(' )
151- ? accent . replace ( 'rgb(' , 'rgba(' ) . replace ( ')' , ',0.12)' )
152- : 'rgba(0,234,255,0.12)'
165+ const color = ( d : BubbleNode ) => PALETTE [ d . colorIdx % PALETTE . length ]
153166
154167 /* D3 join */
155168 const groups = svg . selectAll < SVGGElement , BubbleNode > ( '.bbl' )
@@ -177,8 +190,8 @@ const Viz2: React.FC = () => {
177190 all . select < SVGCircleElement > ( 'circle' )
178191 . transition ( ) . duration ( 350 )
179192 . attr ( 'r' , ( d : BubbleNode ) => d . r )
180- . attr ( 'fill' , fill )
181- . attr ( 'stroke' , accent )
193+ . attr ( 'fill' , ( d : BubbleNode ) => hexToRgba ( color ( d ) , 0.15 ) )
194+ . attr ( 'stroke' , ( d : BubbleNode ) => color ( d ) )
182195 . attr ( 'stroke-width' , 1.5 )
183196
184197 all . select < SVGTextElement > ( 'text' )
@@ -190,12 +203,12 @@ const Viz2: React.FC = () => {
190203 all
191204 . on ( 'mouseover' , ( event : MouseEvent , d : BubbleNode ) => {
192205 d3 . select ( event . currentTarget as SVGGElement )
193- . select ( 'circle' ) . attr ( 'stroke-width' , 2.5 ) . attr ( 'fill' , fill . replace ( '0.12' , '0.22' ) )
206+ . select ( 'circle' ) . attr ( 'stroke-width' , 2.5 ) . attr ( 'fill' , hexToRgba ( color ( d ) , 0.28 ) )
194207 if ( ! chartRef . current ) return
195208 const tip = d3 . select < HTMLDivElement , unknown > ( '.viz2-tooltip' )
196209 const rect = chartRef . current . getBoundingClientRect ( )
197210 tip . style ( 'display' , 'block' )
198- . html ( `<span class="viz2-tt-name">${ d . name } </span><br/>${ d . value } ${ metric } ` )
211+ . html ( `<span class="viz2-tt-name" style="color: ${ color ( d ) } " >${ d . name } </span><br/>${ d . value } ${ metric } ` )
199212 . style ( 'left' , `${ Math . min ( event . clientX - rect . left + 12 , chartSize . w - 160 ) } px` )
200213 . style ( 'top' , `${ Math . max ( event . clientY - rect . top - 50 , 4 ) } px` )
201214 } )
@@ -206,12 +219,36 @@ const Viz2: React.FC = () => {
206219 . style ( 'left' , `${ Math . min ( event . clientX - rect . left + 12 , chartSize . w - 160 ) } px` )
207220 . style ( 'top' , `${ Math . max ( event . clientY - rect . top - 50 , 4 ) } px` )
208221 } )
209- . on ( 'mouseout' , ( event : MouseEvent ) => {
222+ . on ( 'mouseout' , ( event : MouseEvent , d : BubbleNode ) => {
210223 d3 . select ( event . currentTarget as SVGGElement )
211- . select ( 'circle' ) . attr ( 'stroke-width' , 1.5 ) . attr ( 'fill' , fill )
224+ . select ( 'circle' ) . attr ( 'stroke-width' , 1.5 ) . attr ( 'fill' , hexToRgba ( color ( d ) , 0.15 ) )
212225 d3 . select ( '.viz2-tooltip' ) . style ( 'display' , 'none' )
213226 } )
214227
228+ /* drag — free movement, snaps back on release */
229+ const drag = d3 . drag < SVGGElement , BubbleNode > ( )
230+ . on ( 'start' , ( _ev : d3 . D3DragEvent < SVGGElement , BubbleNode , BubbleNode > , d : BubbleNode ) => {
231+ simRef . current ?. alphaTarget ( 0.3 ) . restart ( )
232+ d . fx = d . x ; d . fy = d . y
233+ d . __restX = d . x ?? 0 ; d . __restY = d . y ?? 0
234+ d3 . select ( _ev . sourceEvent . target as SVGCircleElement ) . attr ( 'stroke-width' , 2.5 )
235+ } )
236+ . on ( 'drag' , ( ev : d3 . D3DragEvent < SVGGElement , BubbleNode , BubbleNode > , d : BubbleNode ) => {
237+ d . fx = ev . x
238+ d . fy = ev . y
239+ } )
240+ . on ( 'end' , ( ev : d3 . D3DragEvent < SVGGElement , BubbleNode , BubbleNode > , d : BubbleNode ) => {
241+ simRef . current ?. alphaTarget ( 0 )
242+ d3 . select ( ev . sourceEvent . target as SVGCircleElement ) . attr ( 'stroke-width' , 1.5 )
243+ // give an initial velocity toward rest position for a snappy return
244+ d . vx = ( ( d . __restX ?? 0 ) - ( d . x ?? 0 ) ) * 0.35
245+ d . vy = ( ( d . __restY ?? 0 ) - ( d . y ?? 0 ) ) * 0.35
246+ d . fx = null ; d . fy = null
247+ simRef . current ?. alpha ( 0.45 ) . restart ( )
248+ } )
249+
250+ all . call ( drag )
251+
215252 /* restart simulation */
216253 if ( simRef . current ) {
217254 simRef . current
0 commit comments