135 lines
4.3 KiB
TypeScript
135 lines
4.3 KiB
TypeScript
import type { BrokerPortfolio } from '../../../api/responses';
|
||
import {
|
||
buildBrokerAllocation,
|
||
type BrokerAllocationItem,
|
||
} from '../../../entities/broker-position';
|
||
|
||
const RADIUS = 44;
|
||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||
|
||
function formatMoneyValue(value: number, currency: string) {
|
||
return new Intl.NumberFormat('ru-RU', {
|
||
style: 'currency',
|
||
currency,
|
||
maximumFractionDigits: 2,
|
||
}).format(value);
|
||
}
|
||
|
||
function allocationCurrency(portfolio: BrokerPortfolio) {
|
||
return (
|
||
portfolio.totals.portfolio?.currency ||
|
||
Object.values(portfolio.totals).find((total) => total?.currency)?.currency ||
|
||
'RUB'
|
||
);
|
||
}
|
||
|
||
export function BrokerAllocationBar({
|
||
items,
|
||
title,
|
||
}: {
|
||
items: BrokerAllocationItem[];
|
||
title: string;
|
||
}) {
|
||
const positiveItems = items.filter((item) => item.value > 0);
|
||
|
||
if (positiveItems.length === 0) {
|
||
return <p className="broker-allocation-bar__empty">Нет данных для распределения</p>;
|
||
}
|
||
|
||
return (
|
||
<div className="broker-allocation-bar">
|
||
<div className="broker-allocation-bar__track" role="img" aria-label={title}>
|
||
{positiveItems.map((item) => (
|
||
<span
|
||
key={item.key}
|
||
className="broker-allocation-bar__segment"
|
||
style={{ width: `${item.percent}%`, background: item.color }}
|
||
aria-hidden="true"
|
||
/>
|
||
))}
|
||
</div>
|
||
<ul className="broker-allocation-bar__legend" aria-label={`${title}: легенда`}>
|
||
{positiveItems.map((item) => (
|
||
<li className="broker-allocation-bar__legend-item" key={item.key}>
|
||
<span
|
||
className="broker-allocation-bar__swatch"
|
||
style={{ background: item.color }}
|
||
aria-hidden="true"
|
||
/>
|
||
<span>{item.label}</span>
|
||
<strong>{item.percent.toFixed(0)}%</strong>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfolio }) {
|
||
const { sectors, negative } = buildBrokerAllocation(portfolio);
|
||
const currency = allocationCurrency(portfolio);
|
||
let remaining = CIRCUMFERENCE;
|
||
const arcs = sectors.map((sector) => {
|
||
const dashOffset = -(CIRCUMFERENCE - remaining);
|
||
const rawDashLength = (sector.percent / 100) * CIRCUMFERENCE;
|
||
const dashLength = Math.min(Math.max(rawDashLength, 0), remaining);
|
||
remaining = Math.max(0, remaining - dashLength);
|
||
return { ...sector, dashOffset, dashLength };
|
||
});
|
||
|
||
return (
|
||
<figure className="broker-allocation">
|
||
<svg role="img" aria-label="Структура брокерского портфеля" viewBox="0 0 120 120">
|
||
<title>Структура брокерского портфеля</title>
|
||
{arcs.map((sector) => (
|
||
<circle
|
||
key={sector.key}
|
||
cx="60"
|
||
cy="60"
|
||
r={RADIUS}
|
||
fill="none"
|
||
stroke={sector.color}
|
||
strokeWidth="14"
|
||
strokeDasharray={`${sector.dashLength} ${CIRCUMFERENCE - sector.dashLength}`}
|
||
strokeDashoffset={sector.dashOffset}
|
||
transform="rotate(-90 60 60)"
|
||
/>
|
||
))}
|
||
</svg>
|
||
<figcaption>
|
||
{sectors.length === 0 ? (
|
||
<p>Нет данных для распределения</p>
|
||
) : (
|
||
<ul>
|
||
{sectors.map((sector) => (
|
||
<li key={sector.key}>
|
||
<span
|
||
className="broker-allocation__swatch"
|
||
aria-hidden="true"
|
||
style={{ background: sector.color }}
|
||
/>
|
||
<span>
|
||
{sector.label}: {formatMoneyValue(sector.value, currency)} ·{' '}
|
||
{sector.percent.toFixed(1)}%
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
{negative.length > 0 && (
|
||
<ul
|
||
className="broker-allocation__negative"
|
||
aria-label="Отрицательные значения распределения"
|
||
>
|
||
{negative.map((item) => (
|
||
<li key={item.key}>
|
||
{item.label}: отрицательное значение {formatMoneyValue(item.value, currency)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</figcaption>
|
||
</figure>
|
||
);
|
||
}
|