7.2 KiB
Raw Permalink Blame History

Диаграмма распределения портфеля — План реализации

Для агентов: Требуется навык 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: без изменений