Skip to content

Commit c004e57

Browse files
committed
feat: make strategy component abstract
1 parent 5fbf429 commit c004e57

File tree

6 files changed

+740
-556
lines changed

6 files changed

+740
-556
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import {useMemo, useRef, useState} from 'react';
2+
3+
import type {MouseEvent, ReactElement} from 'react';
4+
import type {TYDaemonVaultStrategy} from '@yearn-finance/web-lib/utils/schemas/yDaemonVaultsSchemas';
5+
import type {TAddress} from '@builtbymom/web3/types';
6+
7+
/************************************************************************************************
8+
* AllocationPercentage Component
9+
* Displays a donut chart representing the allocation percentages of various strategies
10+
* Uses SVG arcs for visual rendering with improved mouse position tracking
11+
* Shows "allocation %" text in the center of the chart
12+
* Displays vault name in a tooltip when hovering over a segment
13+
************************************************************************************************/
14+
export function AllocationPercentage({allocationList}: {allocationList: TYDaemonVaultStrategy[]}): ReactElement {
15+
const [hoveredSegment, set_hoveredSegment] = useState<{
16+
address: TAddress;
17+
name: string;
18+
percentage: number;
19+
x: number;
20+
y: number;
21+
} | null>(null);
22+
23+
const chartRef = useRef<HTMLDivElement>(null);
24+
25+
// Calculate the segments for the pie chart
26+
const segments = useMemo(() => {
27+
let cumulativePercentage = 0;
28+
29+
return allocationList.map(vault => {
30+
const startPercentage = cumulativePercentage;
31+
cumulativePercentage += (vault.details?.debtRatio || 0) / 10000;
32+
return {
33+
address: vault.address as TAddress,
34+
name: vault.name,
35+
percentage: (vault.details?.debtRatio || 0) / 10000,
36+
startPercentage,
37+
endPercentage: cumulativePercentage
38+
};
39+
});
40+
}, [allocationList]);
41+
42+
// Handle mouse movement over the chart area
43+
const handleMouseMove = (event: MouseEvent): void => {
44+
if (!chartRef.current) return;
45+
46+
const rect = chartRef.current.getBoundingClientRect();
47+
const x = event.clientX - rect.left;
48+
const y = event.clientY - rect.top;
49+
50+
// Calculate the center point of the chart
51+
const centerX = rect.width / 2;
52+
const centerY = rect.height / 2;
53+
54+
// Calculate distance from center (to check if within donut ring)
55+
const distanceFromCenter = Math.sqrt(Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2));
56+
57+
// Chart radius and donut thickness
58+
const radius = rect.width / 2;
59+
const innerRadius = radius * 0.7; // 70% for inner hole
60+
61+
// Check if cursor is within the donut ring
62+
if (distanceFromCenter > innerRadius && distanceFromCenter < radius) {
63+
// Calculate angle in radians, then convert to percentage around the circle
64+
let angle = Math.atan2(y - centerY, x - centerX);
65+
66+
// Convert angle to 0-360 degrees, starting from top (negative Y axis)
67+
angle = angle * (180 / Math.PI); // Convert to degrees
68+
if (angle < 0) angle += 360; // Convert to 0-360 range
69+
angle = (angle + 90) % 360; // Rotate to start from top
70+
71+
// Find which segment this angle belongs to
72+
const percentage = angle / 360;
73+
74+
// Find the segment containing this percentage
75+
for (const segment of segments) {
76+
if (percentage >= segment.startPercentage && percentage <= segment.endPercentage) {
77+
set_hoveredSegment({
78+
address: segment.address,
79+
name: segment.name,
80+
percentage: segment.percentage,
81+
x,
82+
y
83+
});
84+
return;
85+
}
86+
}
87+
}
88+
89+
// Not over any segment
90+
set_hoveredSegment(null);
91+
};
92+
93+
const handleMouseLeave = (): void => {
94+
set_hoveredSegment(null);
95+
};
96+
97+
// SVG path generation
98+
const createArcPath = (
99+
startPercentage: number,
100+
endPercentage: number,
101+
radius: number,
102+
thickness: number
103+
): string => {
104+
// Add small gap between segments
105+
const gapAngle = 0.005; // 0.5% gap
106+
const adjustedStartPercentage = startPercentage + gapAngle;
107+
const adjustedEndPercentage = endPercentage - gapAngle;
108+
109+
// Skip tiny segments
110+
if (adjustedEndPercentage <= adjustedStartPercentage) return '';
111+
112+
const startAngle = adjustedStartPercentage * Math.PI * 2 - Math.PI / 2;
113+
const endAngle = adjustedEndPercentage * Math.PI * 2 - Math.PI / 2;
114+
115+
const innerRadius = radius - thickness;
116+
const outerRadius = radius;
117+
118+
// Calculate points
119+
const startOuterX = 100 + outerRadius * Math.cos(startAngle);
120+
const startOuterY = 100 + outerRadius * Math.sin(startAngle);
121+
const endOuterX = 100 + outerRadius * Math.cos(endAngle);
122+
const endOuterY = 100 + outerRadius * Math.sin(endAngle);
123+
124+
const startInnerX = 100 + innerRadius * Math.cos(startAngle);
125+
const startInnerY = 100 + innerRadius * Math.sin(startAngle);
126+
const endInnerX = 100 + innerRadius * Math.cos(endAngle);
127+
const endInnerY = 100 + innerRadius * Math.sin(endAngle);
128+
129+
// Determine if the arc is more than 180 degrees
130+
const largeArcFlag = endAngle - startAngle > Math.PI ? 1 : 0;
131+
132+
// Create path
133+
return `
134+
M ${startOuterX} ${startOuterY}
135+
A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} 1 ${endOuterX} ${endOuterY}
136+
L ${endInnerX} ${endInnerY}
137+
A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} 0 ${startInnerX} ${startInnerY}
138+
Z
139+
`;
140+
};
141+
142+
return (
143+
<div className={'flex size-full flex-col items-center justify-center'}>
144+
<div
145+
ref={chartRef}
146+
className={'relative size-[200px]'}
147+
onMouseMove={handleMouseMove}
148+
onMouseLeave={handleMouseLeave}>
149+
{/* SVG donut chart */}
150+
<svg
151+
className={'pointer-events-none size-full'}
152+
viewBox={'0 0 200 200'}>
153+
{segments.map((segment, index) => {
154+
const arcPath = createArcPath(segment.startPercentage, segment.endPercentage, 90, 20);
155+
if (!arcPath) return null;
156+
157+
return (
158+
<path
159+
key={`segment-${index}`}
160+
d={arcPath}
161+
fill={'currentColor'}
162+
className={'text-[#000838] dark:text-white'}
163+
/>
164+
);
165+
})}
166+
</svg>
167+
168+
{/* Donut hole */}
169+
<div
170+
className={
171+
'pointer-events-none absolute left-1/2 top-1/2 size-[140px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-transparent'
172+
}>
173+
{/* Center text */}
174+
<div className={'flex size-full items-center justify-center'}>
175+
<span className={'text-center font-normal text-neutral-900'}>{'allocation %'}</span>
176+
</div>
177+
</div>
178+
179+
{/* Tooltip */}
180+
{hoveredSegment && (
181+
<div
182+
className={
183+
'pointer-events-none absolute z-10 rounded bg-neutral-900 px-3 py-2 text-xs text-white shadow-lg dark:bg-neutral-300'
184+
}
185+
style={{
186+
bottom: -hoveredSegment.y + 200,
187+
left: hoveredSegment.x,
188+
right: -hoveredSegment.x
189+
}}>
190+
<ul className={'flex flex-col gap-1'}>
191+
<li className={'flex items-center gap-2'}>
192+
<div className={'size-1.5 min-w-1.5 shrink-0 rounded-full bg-white'} />
193+
<p className={'max-w-[200px] font-medium'}>{hoveredSegment.name}</p>
194+
</li>
195+
<li className={'flex items-center gap-2'}>
196+
<div className={'size-1.5 min-w-1.5 shrink-0 rounded-full bg-white'} />
197+
<p>
198+
{(hoveredSegment.percentage * 100).toFixed(2)}
199+
{'%'}
200+
</p>
201+
</li>
202+
</ul>
203+
</div>
204+
)}
205+
</div>
206+
</div>
207+
);
208+
}

0 commit comments

Comments
 (0)