262 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Диаграмма распределения портфеля — План реализации
> **Для агентов:** Требуется навык `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 (
<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**
```tsx
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: без изменений