Skip to content

Commit f9f6161

Browse files
authored
Merge pull request #195 from r4topunk/feat/orbit-ens-focus
feat(stake): orbit — ENS/basenames + click a rider to recenter
2 parents 0497a78 + 26d6b99 commit f9f6161

3 files changed

Lines changed: 82 additions & 18 deletions

File tree

messages/en/stake.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@
7272
"treasury": "TREASURY",
7373
"earnedForTreasury": "Earned for treasury",
7474
"legendVault": "Morpho vault",
75-
"legendMor": "Morpheus · MOR"
75+
"legendMor": "Morpheus · MOR",
76+
"backToAll": "All riders"
7677
},
7778
"backing": "<b>{amount}</b> backing {name}",
7879
"loadingSupport": "loading support…",

messages/pt-br/stake.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@
7272
"treasury": "TESOURO",
7373
"earnedForTreasury": "Rendeu pro tesouro",
7474
"legendVault": "Vault Morpho",
75-
"legendMor": "Morpheus · MOR"
75+
"legendMor": "Morpheus · MOR",
76+
"backToAll": "Todos os riders"
7677
},
7778
"backing": "<b>{amount}</b> apoiando {name}",
7879
"loadingSupport": "carregando apoio…",

src/components/stake/StakeOrbit.tsx

Lines changed: 78 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
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";
910
import { useTranslations } from "next-intl";
1011
import { useActiveAccount } from "thirdweb/react";
1112
import { useStakeGraph } from "@/hooks/use-stake-graph";
@@ -24,6 +25,12 @@ const RIDER_VISUAL: Record<RiderId, { hex: string; image: string; face: { size:
2425

2526
const usd = (n: number) => `$${n.toLocaleString("en-US", { maximumFractionDigits: n > 0 && n < 100 ? 2 : 0 })}`;
2627
const 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+
};
2734
const 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

Comments
 (0)