# Диаграмма распределения портфеля — План реализации > **Для агентов:** Требуется навык `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** ```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 ( ); } if (nonZero.length === 1) { const sector = nonZero[0]; return ( ); } 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 ( ); }); } return (
{renderArcs()} 0 ? 14 : 10, fontWeight: 700, fill: 'var(--color-text)', }} > {positions.length === 0 ? 'Нет позиций' : totalValue.toLocaleString('ru-RU', { maximumFractionDigits: 0 })}
{sectors.map((s) => { const ratio = totalValue > 0 ? (s.value / totalValue) * 100 : 0; return (
{s.label}: {s.count} / {ratio.toFixed(1)}%
); })}
); } ``` - [ ] **Шаг 2: Проверить сборку** Run: `npm run build:frontend` Expected: без ошибок --- ### Задача 2: Интегрировать в PortfolioSummary **Файлы:** - Изменить: `apps/frontend/src/components/portfolios/PortfolioSummary.tsx` - [ ] **Шаг 1: Обновить PortfolioSummary** ```tsx import { AllocationChart } from './AllocationChart'; import type { PortfolioDetail } from '../../api/responses'; export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) { return (
Общая стоимость
{portfolio.totalValue.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2, })} {portfolio.currency}
Позиций
{portfolio.positions.length}
); } ``` - [ ] **Шаг 2: Проверить сборку** Run: `npm run build:frontend` Expected: без ошибок - [ ] **Шаг 3: Проверить линтер** Run: `npm run lint` Expected: без ошибок - [ ] **Шаг 4: Проверить форматирование** Run: `npm run format` Expected: без изменений