66// per-rider supporters list, this shows a backer's positions across every rider
77// at once (which is why "I don't see all my stakes" happens on the flat list).
88
9+ import { useEffect , useState } from "react" ;
910import { useTranslations } from "next-intl" ;
1011import { useActiveAccount } from "thirdweb/react" ;
1112import { useStakeGraph } from "@/hooks/use-stake-graph" ;
@@ -24,6 +25,12 @@ const RIDER_VISUAL: Record<RiderId, { hex: string; image: string; face: { size:
2425
2526const usd = ( n : number ) => `$${ n . toLocaleString ( "en-US" , { maximumFractionDigits : n > 0 && n < 100 ? 2 : 0 } ) } ` ;
2627const short = ( a : string ) => `${ a . slice ( 0 , 5 ) } …${ a . slice ( - 3 ) } ` ;
28+ /** Prefer an ENS/basename, trimmed to fit the small orbit label. */
29+ const nameOrShort = ( addr : string , names : Record < string , string > ) => {
30+ const n = names [ addr . toLowerCase ( ) ] ;
31+ if ( ! n ) return short ( addr ) ;
32+ return n . length > 16 ? `${ n . slice ( 0 , 15 ) } …` : n ;
33+ } ;
2734const GOLD = "#f7c948" ;
2835// Morpheus green — MOR stakes get their own colored stream, distinct from the
2936// rider-tinted Morpho vault flows, so a wallet that backs both shows two lines.
@@ -41,6 +48,36 @@ export function StakeOrbit() {
4148 const you = useActiveAccount ( ) ?. address ?. toLowerCase ( ) ;
4249 const graph = useStakeGraph ( ) ;
4350
51+ // Click an athlete to recenter the orbit on them; click "all" to zoom out.
52+ const [ focusId , setFocusId ] = useState < RiderId | null > ( null ) ;
53+ // Resolve backer addresses to ENS / basenames (batched, cached by /api/ens).
54+ const [ ensNames , setEnsNames ] = useState < Record < string , string > > ( { } ) ;
55+ useEffect ( ( ) => {
56+ if ( ! graph ) return ;
57+ const addrs = Array . from (
58+ new Set ( graph . athletes . flatMap ( ( a ) => a . backers . map ( ( b ) => b . address . toLowerCase ( ) ) ) ) ,
59+ ) ;
60+ if ( addrs . length === 0 ) return ;
61+ let cancelled = false ;
62+ ( async ( ) => {
63+ try {
64+ const res = await fetch ( "/api/ens" , {
65+ method : "POST" ,
66+ headers : { "Content-Type" : "application/json" } ,
67+ body : JSON . stringify ( { addresses : addrs } ) ,
68+ } ) ;
69+ if ( ! res . ok ) return ;
70+ const json = ( await res . json ( ) ) as { ensMap ?: Record < string , { name ?: string | null } > } ;
71+ const map : Record < string , string > = { } ;
72+ for ( const [ addr , data ] of Object . entries ( json . ensMap ?? { } ) ) {
73+ if ( data ?. name ) map [ addr . toLowerCase ( ) ] = data . name ;
74+ }
75+ if ( ! cancelled ) setEnsNames ( map ) ;
76+ } catch { /* addresses stay short */ }
77+ } ) ( ) ;
78+ return ( ) => { cancelled = true ; } ;
79+ } , [ graph ] ) ;
80+
4481 if ( graph === null ) {
4582 return (
4683 < div className = "rounded-[22px] border border-white/[0.06] bg-[#0e0b09] p-6 text-sm text-white/40" >
@@ -58,6 +95,12 @@ export function StakeOrbit() {
5895 // line weight ∝ amount, floored so a tiny stake is still visible.
5996 const supW = ( amount : number ) => Math . max ( 1.5 , 6 * Math . sqrt ( amount / maxTotal ) ) ;
6097
98+ // Focus mode: the picked athlete takes the center, the treasury slides to a
99+ // small satellite near the top, and everyone else drops away.
100+ const focused = focusId ? athletes . find ( ( a ) => a . id === focusId ) ?? null : null ;
101+ const treasuryPt = focused ? { x : C , y : 96 } : { x : C , y : C } ;
102+ const treasuryR = focused ? 26 : 48 ;
103+
61104 return (
62105 < div className = "rounded-[22px] border border-white/[0.06] bg-gradient-to-b from-[#181410] to-[#0e0b09] p-5 sm:p-7" >
63106 < div className = "mb-4 flex flex-wrap items-end justify-between gap-3" >
@@ -83,6 +126,16 @@ export function StakeOrbit() {
83126 </ div >
84127 </ div >
85128
129+ { focused && (
130+ < button
131+ type = "button"
132+ onClick = { ( ) => setFocusId ( null ) }
133+ className = "mb-3 inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-white/15 bg-black/30 px-3 py-1 text-xs font-semibold text-white/80 transition hover:bg-black/50"
134+ >
135+ ← { t ( "orbit.backToAll" ) }
136+ </ button >
137+ ) }
138+
86139 < div className = "mb-3 flex flex-wrap items-center gap-4 text-[11px] text-white/50" >
87140 < span className = "flex items-center gap-1.5" >
88141 < span className = "h-2 w-2 rounded-full bg-white/60" />
@@ -110,31 +163,35 @@ export function StakeOrbit() {
110163 < circle cx = { C } cy = { C } r = { R_SUP } fill = "none" stroke = "rgba(255,255,255,.04)" />
111164
112165 { athletes . map ( ( a , i ) => {
166+ // In focus mode, render only the focused athlete — at the center.
167+ if ( focused && a . id !== focused . id ) return null ;
168+ const isCenter = ! ! focused && a . id === focused . id ;
113169 const angle = - 90 + ( 360 / n ) * i ;
114- const ap = pt ( angle , R_ATH ) ;
170+ const ap = isCenter ? { x : C , y : C } : pt ( angle , R_ATH ) ;
115171 const v = RIDER_VISUAL [ a . id ] ;
116172 const lit = a . total > 0 ;
173+ const nodeR = isCenter ? 52 : athR ( a . total ) ;
117174
118- // backers fan out within ±22° of the athlete's angle on the outer ring
175+ // backers fan a full circle when centered, else a ±22° arc.
119176 const spread = 44 ;
120177 const bs = a . backers ;
121178 return (
122179 < g key = { a . id } >
123180 { /* spoke: athlete -> treasury (the fee relationship) */ }
124181 < line
125- x1 = { ap . x } y1 = { ap . y } x2 = { C } y2 = { C }
182+ x1 = { ap . x } y1 = { ap . y } x2 = { treasuryPt . x } y2 = { treasuryPt . y }
126183 stroke = { lit ? v . hex : "rgba(255,255,255,.10)" }
127184 strokeOpacity = { lit ? 0.5 : 1 }
128185 strokeWidth = { lit ? 2 : 1 }
129186 />
130187 { lit && (
131- < line x1 = { ap . x } y1 = { ap . y } x2 = { C } y2 = { C } className = "so-flow" stroke = { v . hex } strokeWidth = { 2 } strokeOpacity = { 0.9 } />
188+ < line x1 = { ap . x } y1 = { ap . y } x2 = { treasuryPt . x } y2 = { treasuryPt . y } className = "so-flow" stroke = { v . hex } strokeWidth = { 2 } strokeOpacity = { 0.9 } />
132189 ) }
133190
134191 { /* backer lines + dots + value labels */ }
135192 { bs . map ( ( b , j ) => {
136193 const off = bs . length === 1 ? 0 : ( j / ( bs . length - 1 ) - 0.5 ) * spread ;
137- const bp = pt ( angle + off , R_SUP ) ;
194+ const bp = isCenter ? pt ( - 90 + ( 360 / bs . length ) * j , R_SUP ) : pt ( angle + off , R_SUP ) ;
138195 const mid = { x : ( ap . x + bp . x ) / 2 , y : ( ap . y + bp . y ) / 2 } ;
139196 const isYou = you && b . address . toLowerCase ( ) === you ;
140197 const isMor = b . kind === "mor" ;
@@ -160,16 +217,21 @@ export function StakeOrbit() {
160217 </ text >
161218 ) }
162219 < text x = { bp . x } y = { bp . y + ( bp . y < C ? - 14 : 20 ) } textAnchor = "middle" fontSize = "9.5" fill = { isYou ? GOLD : "rgba(255,255,255,.55)" } fontFamily = "monospace" fontWeight = { isYou ? 700 : 400 } >
163- { isYou ? t ( "orbit.you" ) : short ( b . address ) }
220+ { isYou ? t ( "orbit.you" ) : nameOrShort ( b . address , ensNames ) }
164221 </ text >
165222 </ g >
166223 ) ;
167224 } ) }
168225
169- { /* athlete node — cut-out zoomed onto the face, clipped round */ }
170- < g transform = { `translate(${ ap . x } ${ ap . y } )` } >
171- < circle r = { athR ( a . total ) } fill = "#141210" stroke = { lit ? v . hex : "rgba(255,255,255,.18)" } strokeWidth = { lit ? 2.5 : 1.5 } />
172- < foreignObject x = { - athR ( a . total ) } y = { - athR ( a . total ) } width = { athR ( a . total ) * 2 } height = { athR ( a . total ) * 2 } >
226+ { /* athlete node — cut-out zoomed onto the face, clipped round.
227+ Click to recenter on this rider (or zoom back out). */ }
228+ < g
229+ transform = { `translate(${ ap . x } ${ ap . y } )` }
230+ style = { { cursor : "pointer" } }
231+ onClick = { ( ) => setFocusId ( isCenter ? null : a . id ) }
232+ >
233+ < circle r = { nodeR } fill = "#141210" stroke = { lit ? v . hex : "rgba(255,255,255,.18)" } strokeWidth = { lit ? 2.5 : 1.5 } />
234+ < foreignObject x = { - nodeR } y = { - nodeR } width = { nodeR * 2 } height = { nodeR * 2 } >
173235 < div
174236 style = { {
175237 width : "100%" , height : "100%" , borderRadius : "50%" ,
@@ -181,19 +243,19 @@ export function StakeOrbit() {
181243 </ foreignObject >
182244 </ g >
183245 { /* athlete name + total */ }
184- < text x = { ap . x } y = { ap . y + athR ( a . total ) + 16 } textAnchor = "middle" fontSize = "13" fontWeight = "800" fill = "#fff" >
246+ < text x = { ap . x } y = { ap . y + nodeR + 16 } textAnchor = "middle" fontSize = "13" fontWeight = "800" fill = "#fff" >
185247 { t ( `characters.${ a . id } .name` ) }
186248 </ text >
187- < text x = { ap . x } y = { ap . y + athR ( a . total ) + 31 } textAnchor = "middle" fontSize = "12" fontWeight = "700" fontFamily = "monospace" fill = { lit ? v . hex : "rgba(255,255,255,.4)" } >
249+ < text x = { ap . x } y = { ap . y + nodeR + 31 } textAnchor = "middle" fontSize = "12" fontWeight = "700" fontFamily = "monospace" fill = { lit ? v . hex : "rgba(255,255,255,.4)" } >
188250 { a . total > 0 ? usd ( a . total ) : "—" }
189251 </ text >
190252 </ g >
191253 ) ;
192254 } ) }
193255
194- { /* treasury center — the Gnars logo */ }
195- < circle cx = { C } cy = { C } r = { 48 } fill = "#0c0a08" stroke = { GOLD } strokeWidth = { 3 } />
196- < foreignObject x = { C - 44 } y = { C - 44 } width = { 88 } height = { 88 } >
256+ { /* treasury — center in overview, a small satellite when focused */ }
257+ < circle cx = { treasuryPt . x } cy = { treasuryPt . y } r = { treasuryR } fill = "#0c0a08" stroke = { GOLD } strokeWidth = { 3 } />
258+ < foreignObject x = { treasuryPt . x - ( treasuryR - 4 ) } y = { treasuryPt . y - ( treasuryR - 4 ) } width = { ( treasuryR - 4 ) * 2 } height = { ( treasuryR - 4 ) * 2 } >
197259 < div
198260 style = { {
199261 width : "100%" , height : "100%" , borderRadius : "50%" ,
@@ -202,7 +264,7 @@ export function StakeOrbit() {
202264 } }
203265 />
204266 </ foreignObject >
205- < text x = { C } y = { C + 68 } textAnchor = "middle" fontSize = "11" fontWeight = "800" letterSpacing = "1.5" fill = "rgba(255,255,255,.6)" > { t ( "orbit.treasury" ) } </ text >
267+ < text x = { treasuryPt . x } y = { treasuryPt . y + treasuryR + 16 } textAnchor = "middle" fontSize = "11" fontWeight = "800" letterSpacing = "1.5" fill = "rgba(255,255,255,.6)" > { t ( "orbit.treasury" ) } </ text >
206268 </ svg >
207269 </ div >
208270 </ div >
0 commit comments