feat: add allocation donut chart to portfolio summary
Some checks failed
Some checks failed
This commit is contained in:
parent
a980520261
commit
4c15bda30e
162
apps/frontend/src/components/portfolios/AllocationChart.tsx
Normal file
162
apps/frontend/src/components/portfolios/AllocationChart.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import { AllocationChart } from './AllocationChart';
|
||||
import type { PortfolioDetail } from '../../api/responses';
|
||||
|
||||
export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) {
|
||||
@ -12,6 +13,7 @@ export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail })
|
||||
borderRadius: 'var(--border-radius)',
|
||||
}}
|
||||
>
|
||||
<AllocationChart positions={portfolio.positions} totalValue={portfolio.totalValue} />
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
|
||||
Общая стоимость
|
||||
|
||||
261
docs/superpowers/plans/2026-06-14-portfolio-allocation-chart.md
Normal file
261
docs/superpowers/plans/2026-06-14-portfolio-allocation-chart.md
Normal file
@ -0,0 +1,261 @@
|
||||
# Диаграмма распределения портфеля — План реализации
|
||||
|
||||
> **Для агентов:** Требуется навык `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: без изменений
|
||||
@ -0,0 +1,54 @@
|
||||
# Диаграмма распределения портфеля — Спецификация
|
||||
|
||||
## Обзор
|
||||
Добавить аналитическую диаграмму (donut) на страницу детального просмотра портфеля, показывающую распределение стоимости между акциями и облигациями, с количеством позиций в легенде.
|
||||
|
||||
## Источник данных
|
||||
`PortfolioDetail.positions[]` — бэкенд уже возвращает:
|
||||
- `type: 'share' | 'bond'`
|
||||
- `currentValue: number | null`
|
||||
- `quantity: number`
|
||||
|
||||
Агрегация на клиенте. Изменений бэкенда не требуется.
|
||||
|
||||
## Компонент: `AllocationChart`
|
||||
- Чистый SVG (два сектора + текст в центре)
|
||||
- Без внешних зависимостей
|
||||
- Размещается внутри `PortfolioSummary` первым ребенком flex-ряда
|
||||
- В центре — общая стоимость портфеля (или «Нет позиций»)
|
||||
- Справа — легенда с разбивкой по типу: количество / процент
|
||||
|
||||
### Цветовая схема
|
||||
| Сектор | Цвет | Легенда |
|
||||
|--------|------|---------|
|
||||
| Акции | `var(--color-primary)` / #1976d2 | `Акции: N / XX.X%` |
|
||||
| Облигации | #f57c00 | `Облигации: N / XX.X%` |
|
||||
|
||||
### Состояния
|
||||
- **Обычное (оба типа):** две дуги пропорционально стоимости, в центре — сумма
|
||||
- **Один тип (только акции или только облигации):** полный круг цветом сектора
|
||||
- **Пусто (нет позиций):** серый круг, в центре «Нет позиций»
|
||||
|
||||
### Расположение
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ [⭕ donut] Общая стоимость Позиций │
|
||||
│ [xxx,xxx] xxx,xxx.xx RUB 12 │
|
||||
│ Акции: 8 / 70.5% │
|
||||
│ Облигации: 4 / 29.5% │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Изменяемые файлы
|
||||
|
||||
### Новый
|
||||
- `apps/frontend/src/components/portfolios/AllocationChart.tsx`
|
||||
|
||||
### Изменяемый
|
||||
- `apps/frontend/src/components/portfolios/PortfolioSummary.tsx` — импорт и рендер `AllocationChart`
|
||||
|
||||
## Граничные случаи
|
||||
|
||||
- `currentValue === null` — считается как 0
|
||||
- У всех позиций `currentValue === 0` — серый круг, в центре «0»
|
||||
- Один из типов отсутствует (0 позиций) — в легенде `0 / 0.0%`, дуга заполняет 100% для непустого типа
|
||||
Loading…
x
Reference in New Issue
Block a user