7.2 KiB
Диаграмма распределения портфеля — План реализации
Для агентов: Требуется навык
superpowers:subagent-driven-developmentилиsuperpowers:executing-plans. Шаги используют- [ ].
Цель: Добавить SVG-диаграмму donut в PortfolioSummary, показывающую распределение стоимости между акциями и облигациями.
Архитектура: Всё на клиенте. Бэкенд уже возвращает positions с currentValue и type. Новый компонент AllocationChart агрегирует данные и рисует SVG. PortfolioSummary включает его.
Технологии: React 18, SVG (без библиотек).
Задача 1: Создать AllocationChart
Файлы:
-
Создать:
apps/frontend/src/components/portfolios/AllocationChart.tsx -
Шаг 1: Создать AllocationChart.tsx
import type { PositionWithPrice } from '../../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>
);
}
- Шаг 2: Проверить сборку
Run: npm run build:frontend
Expected: без ошибок
Задача 2: Интегрировать в PortfolioSummary
Файлы:
-
Изменить:
apps/frontend/src/components/portfolios/PortfolioSummary.tsx -
Шаг 1: Обновить PortfolioSummary
import { AllocationChart } from './AllocationChart';
import type { PortfolioDetail } from '../../api/responses';
export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) {
return (
<div
style={{
display: 'flex',
gap: 32,
padding: 20,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
}}
>
<AllocationChart positions={portfolio.positions} totalValue={portfolio.totalValue} />
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
Общая стоимость
</div>
<div style={{ fontSize: 24, fontWeight: 700 }}>
{portfolio.totalValue.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
<span
style={{
fontSize: 14,
fontWeight: 400,
color: 'var(--color-text-secondary)',
marginLeft: 4,
}}
>
{portfolio.currency}
</span>
</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
Позиций
</div>
<div style={{ fontSize: 24, fontWeight: 700 }}>{portfolio.positions.length}</div>
</div>
</div>
);
}
- Шаг 2: Проверить сборку
Run: npm run build:frontend
Expected: без ошибок
- Шаг 3: Проверить линтер
Run: npm run lint
Expected: без ошибок
- Шаг 4: Проверить форматирование
Run: npm run format
Expected: без изменений