163 lines
4.1 KiB
TypeScript
163 lines
4.1 KiB
TypeScript
import type { PositionWithPrice } from '@/shared/api/responses';
|
|
|
|
interface AllocationChartProps {
|
|
positions: PositionWithPrice[];
|
|
totalValue: number;
|
|
}
|
|
|
|
interface SectorData {
|
|
type: 'share' | 'bond';
|
|
label: string;
|
|
value: number;
|
|
count: number;
|
|
color: string;
|
|
}
|
|
|
|
const SECTOR_COLORS = {
|
|
share: 'var(--color-primary, #1976d2)',
|
|
bond: '#f57c00',
|
|
} as const;
|
|
|
|
const SECTOR_LABELS = {
|
|
share: 'Акции',
|
|
bond: 'Облигации',
|
|
} as const;
|
|
|
|
function computeSectors(positions: PositionWithPrice[]): SectorData[] {
|
|
const sectors: SectorData[] = [
|
|
{ type: 'share', label: SECTOR_LABELS.share, value: 0, count: 0, color: SECTOR_COLORS.share },
|
|
{ type: 'bond', label: SECTOR_LABELS.bond, value: 0, count: 0, color: SECTOR_COLORS.bond },
|
|
];
|
|
|
|
for (const p of positions) {
|
|
const sector = sectors.find((s) => s.type === p.type);
|
|
if (sector) {
|
|
sector.value += p.currentValue ?? 0;
|
|
sector.count += 1;
|
|
}
|
|
}
|
|
|
|
return sectors;
|
|
}
|
|
|
|
export function AllocationChart({ positions, totalValue }: AllocationChartProps) {
|
|
const sectors = computeSectors(positions);
|
|
const nonZero = sectors.filter((s) => s.value > 0);
|
|
const hasData = nonZero.length > 0;
|
|
|
|
const cx = 60;
|
|
const cy = 60;
|
|
const r = 44;
|
|
const strokeWidth = 10;
|
|
const circumference = 2 * Math.PI * r;
|
|
const viewBoxSize = 120;
|
|
|
|
function renderArcs() {
|
|
if (!hasData) {
|
|
return (
|
|
<circle
|
|
cx={cx}
|
|
cy={cy}
|
|
r={r}
|
|
fill="none"
|
|
stroke="#e0e0e0"
|
|
strokeWidth={strokeWidth}
|
|
transform={`rotate(-90 ${cx} ${cy})`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (nonZero.length === 1) {
|
|
const sector = nonZero[0];
|
|
return (
|
|
<circle
|
|
cx={cx}
|
|
cy={cy}
|
|
r={r}
|
|
fill="none"
|
|
stroke={sector.color}
|
|
strokeWidth={strokeWidth}
|
|
transform={`rotate(-90 ${cx} ${cy})`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return sectors.map((sector, i) => {
|
|
const ratio = totalValue > 0 ? sector.value / totalValue : 0;
|
|
const dashLen = ratio * circumference;
|
|
const gapLen = circumference - dashLen;
|
|
let rotation = -90;
|
|
|
|
for (let j = 0; j < i; j++) {
|
|
const prevRatio = totalValue > 0 ? sectors[j].value / totalValue : 0;
|
|
rotation += prevRatio * 360;
|
|
}
|
|
|
|
return (
|
|
<circle
|
|
key={sector.type}
|
|
cx={cx}
|
|
cy={cy}
|
|
r={r}
|
|
fill="none"
|
|
stroke={sector.color}
|
|
strokeWidth={strokeWidth}
|
|
strokeDasharray={`${dashLen} ${gapLen}`}
|
|
transform={`rotate(${rotation} ${cx} ${cy})`}
|
|
style={{ transition: 'stroke-dasharray 0.3s ease' }}
|
|
/>
|
|
);
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
|
<svg
|
|
width={90}
|
|
height={90}
|
|
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
|
|
style={{ flexShrink: 0 }}
|
|
>
|
|
{renderArcs()}
|
|
<text
|
|
x={cx}
|
|
y={cy}
|
|
textAnchor="middle"
|
|
dominantBaseline="central"
|
|
style={{
|
|
fontSize: hasData && positions.length > 0 ? 14 : 10,
|
|
fontWeight: 700,
|
|
fill: 'var(--color-text)',
|
|
}}
|
|
>
|
|
{positions.length === 0
|
|
? 'Нет позиций'
|
|
: totalValue.toLocaleString('ru-RU', { maximumFractionDigits: 0 })}
|
|
</text>
|
|
</svg>
|
|
<div style={{ fontSize: 13, lineHeight: 1.6 }}>
|
|
{sectors.map((s) => {
|
|
const ratio = totalValue > 0 ? (s.value / totalValue) * 100 : 0;
|
|
return (
|
|
<div key={s.type} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<span
|
|
style={{
|
|
display: 'inline-block',
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 2,
|
|
background: s.color,
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
<span>
|
|
{s.label}: {s.count} / {ratio.toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|