trading-platform/docs/02-definicion-modulos/OQI-008-portfolio-manager/especificaciones/ET-PFM-009-custom-charts-svg-canvas.md
Adrian Flores Cortes cea9ae85f1 docs: Add 8 ET specifications from TASK-002 audit gaps
Complete remaining ET specs identified in INTEGRATION-PLAN:
- ET-EDU-007: Video Player Advanced (554 LOC component)
- ET-MT4-001: WebSocket Integration (BLOCKER - 0% implemented)
- ET-ML-009: Ensemble Signal (Multi-strategy aggregation)
- ET-TRD-009: Risk-Based Position Sizer (391 LOC component)
- ET-TRD-010: Drawing Tools Persistence (backend + store)
- ET-TRD-011: Market Bias Indicator (multi-timeframe analysis)
- ET-PFM-009: Custom Charts (SVG AllocationChart + Canvas PerformanceChart)
- ET-ML-008: ICT Analysis Card (expanded - 294 LOC component)

All specs include:
- Architecture diagrams
- Complete code examples
- API contracts
- Implementation guides
- Testing scenarios

Related: TASK-2026-01-25-002-FRONTEND-COMPREHENSIVE-AUDIT
Priority: P1-P3 (mixed)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 14:20:53 -06:00

26 KiB
Raw Blame History

ET-PFM-009: Custom Charts (SVG + Canvas)

Versión: 1.0.0 Fecha: 2026-01-25 Epic: OQI-008 - Portfolio Manager Componente: Frontend Components (SVG + Canvas) Estado: Implementado (documentación retroactiva) Prioridad: P3


Metadata

Campo Valor
ID ET-PFM-009
Tipo Especificación Técnica
Epic OQI-008
US Relacionada US-PFM-009 (Ver Charts de Portafolio Personalizados)
Componentes AllocationChart.tsx (138 líneas), PerformanceChart.tsx (312 líneas)
Tecnologías SVG (geometría), Canvas API (rendering)
Complejidad Media (matemáticas de geometría + Canvas API)

1. Descripción General

Este módulo implementa 2 custom charts para visualización de portafolio sin dependencias externas de librerías de gráficos:

1.1 AllocationChart (SVG Donut Chart)

  • Tecnología: SVG (path elements)
  • Tipo: Donut chart
  • Propósito: Visualizar distribución de activos en portafolio
  • Features: Hover tooltips, colores por asset, leyenda

1.2 PerformanceChart (Canvas Line Chart)

  • Tecnología: Canvas 2D API
  • Tipo: Line chart con área rellena
  • Propósito: Mostrar evolución del valor del portafolio en el tiempo
  • Features: Hover crosshair, multi-period, grid lines, gradient fill

2. AllocationChart (SVG Donut Chart)

2.1 Arquitectura SVG

┌────────────────────────────────────────────────────────┐
│         AllocationChart Architecture (SVG)             │
├────────────────────────────────────────────────────────┤
│                                                         │
│  Allocations Data:                                      │
│  [                                                      │
│    { asset: 'BTC', value: 5000, currentPercent: 50 },  │
│    { asset: 'ETH', value: 3000, currentPercent: 30 },  │
│    { asset: 'SOL', value: 2000, currentPercent: 20 }   │
│  ]                                                      │
│         │                                               │
│         v                                               │
│  ┌──────────────────────────────────────────────────┐  │
│  │  Step 1: Calculate Angles                       │  │
│  │  BTC: 0° to 180° (50% of 360°)                  │  │
│  │  ETH: 180° to 288° (30% of 360°)                │  │
│  │  SOL: 288° to 360° (20% of 360°)                │  │
│  └──────────────┬───────────────────────────────────┘  │
│                 │                                       │
│                 v                                       │
│  ┌──────────────────────────────────────────────────┐  │
│  │  Step 2: Convert to Radians                     │  │
│  │  angleRad = (angleDeg × π) / 180                │  │
│  └──────────────┬───────────────────────────────────┘  │
│                 │                                       │
│                 v                                       │
│  ┌──────────────────────────────────────────────────┐  │
│  │  Step 3: Calculate Arc Points                   │  │
│  │  x = centerX + radius × cos(angle)              │  │
│  │  y = centerY + radius × sin(angle)              │  │
│  │                                                  │  │
│  │  Outer arc: radius = 100px                      │  │
│  │  Inner arc: radius = 60px (donut hole)          │  │
│  └──────────────┬───────────────────────────────────┘  │
│                 │                                       │
│                 v                                       │
│  ┌──────────────────────────────────────────────────┐  │
│  │  Step 4: Generate SVG Path Data                 │  │
│  │  M x1Inner y1Inner   (Move to inner start)      │  │
│  │  L x1 y1             (Line to outer start)      │  │
│  │  A r r 0 largeArc 1 x2 y2  (Outer arc)          │  │
│  │  L x2Inner y2Inner   (Line to inner end)        │  │
│  │  A rInner rInner 0 largeArc 0 x1Inner y1Inner   │  │
│  │  Z                   (Close path)               │  │
│  └──────────────┬───────────────────────────────────┘  │
│                 │                                       │
│                 v                                       │
│  ┌──────────────────────────────────────────────────┐  │
│  │  Step 5: Render SVG <path> Elements             │  │
│  │  <path d="M 60 100 L ..." fill="#F7931A" />     │  │
│  └──────────────────────────────────────────────────┘  │
│                                                         │
└────────────────────────────────────────────────────────┘

2.2 Código Matemático

// SVG donut chart geometry
const radius = size / 2;           // 100px (outer radius)
const innerRadius = radius * 0.6;  // 60px (inner radius for donut hole)
const centerX = radius;
const centerY = radius;

let currentAngle = -90; // Start from top (12 o'clock)

const segments = allocations.map((alloc) => {
  // Calculate angles
  const angle = (alloc.currentPercent / 100) * 360;
  const startAngle = currentAngle;
  const endAngle = currentAngle + angle;
  currentAngle = endAngle;

  // Convert to radians
  const startRad = (startAngle * Math.PI) / 180;
  const endRad = (endAngle * Math.PI) / 180;

  // Calculate arc points (polar to cartesian)
  // Outer arc
  const x1 = centerX + radius * Math.cos(startRad);
  const y1 = centerY + radius * Math.sin(startRad);
  const x2 = centerX + radius * Math.cos(endRad);
  const y2 = centerY + radius * Math.sin(endRad);

  // Inner arc (donut hole)
  const x1Inner = centerX + innerRadius * Math.cos(startRad);
  const y1Inner = centerY + innerRadius * Math.sin(startRad);
  const x2Inner = centerX + innerRadius * Math.cos(endRad);
  const y2Inner = centerY + innerRadius * Math.sin(endRad);

  // Large arc flag (>180°)
  const largeArc = angle > 180 ? 1 : 0;

  // SVG Path data
  const pathData = [
    `M ${x1Inner} ${y1Inner}`,  // Move to inner start
    `L ${x1} ${y1}`,            // Line to outer start
    `A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2}`,  // Outer arc (clockwise)
    `L ${x2Inner} ${y2Inner}`,  // Line to inner end
    `A ${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${x1Inner} ${y1Inner}`,  // Inner arc (counter-clockwise)
    'Z',  // Close path
  ].join(' ');

  return { ...alloc, pathData, color: getAssetColor(alloc.asset) };
});

Polar to Cartesian Conversion:

x = centerX + radius × cos(θ)
y = centerY + radius × sin(θ)

2.3 Props Interface

interface AllocationChartProps {
  allocations: PortfolioAllocation[];
  size?: number;  // Default: 200px
}

interface PortfolioAllocation {
  asset: string;           // 'BTC', 'ETH', 'SOL'
  value: number;           // Dollar value
  currentPercent: number;  // Percentage of portfolio (0-100)
}

2.4 Asset Color Palette

const ASSET_COLORS: Record<string, string> = {
  BTC: '#F7931A',   // Bitcoin orange
  ETH: '#627EEA',   // Ethereum blue
  USDT: '#26A17B',  // Tether green
  SOL: '#9945FF',   // Solana purple
  LINK: '#2A5ADA',  // Chainlink blue
  AVAX: '#E84142',  // Avalanche red
  ADA: '#0033AD',   // Cardano blue
  DOT: '#E6007A',   // Polkadot pink
  MATIC: '#8247E5', // Polygon purple
  default: '#6B7280', // Gray fallback
};

2.5 SVG Rendering

<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
  {segments.map((segment, index) => (
    <path
      key={index}
      d={segment.pathData}
      fill={segment.color}
      className="transition-opacity hover:opacity-80 cursor-pointer"
    >
      <title>
        {segment.asset}: {segment.currentPercent.toFixed(1)}% ($
        {segment.value.toLocaleString()})
      </title>
    </path>
  ))}

  {/* Center text */}
  <text x={centerX} y={centerY - 10} textAnchor="middle" className="text-xs text-gray-500">
    Valor Total
  </text>
  <text x={centerX} y={centerY + 15} textAnchor="middle" className="text-lg font-bold">
    ${totalValue.toLocaleString()}
  </text>
</svg>

2.6 Legend

<div className="flex flex-wrap justify-center gap-3 mt-4">
  {allocations.map((alloc) => (
    <div key={alloc.asset} className="flex items-center gap-2">
      <div
        className="w-3 h-3 rounded-full"
        style={{ backgroundColor: getAssetColor(alloc.asset) }}
      />
      <span className="text-sm text-gray-600">
        {alloc.asset} ({alloc.currentPercent.toFixed(1)}%)
      </span>
    </div>
  ))}
</div>

3. PerformanceChart (Canvas Line Chart)

3.1 Arquitectura Canvas

┌──────────────────────────────────────────────────────────┐
│      PerformanceChart Architecture (Canvas API)          │
├──────────────────────────────────────────────────────────┤
│                                                           │
│  Performance Data:                                        │
│  [                                                        │
│    { date: '2026-01-01', value: 10000, pnl: 0 },         │
│    { date: '2026-01-02', value: 10150, pnl: 150 },       │
│    { date: '2026-01-03', value: 10200, pnl: 200 },       │
│    ...                                                    │
│  ]                                                        │
│         │                                                 │
│         v                                                 │
│  ┌────────────────────────────────────────────────────┐  │
│  │  Step 1: Setup Canvas (High DPI)                  │  │
│  │  dpr = window.devicePixelRatio (2x on Retina)     │  │
│  │  canvas.width = rect.width × dpr                  │  │
│  │  ctx.scale(dpr, dpr)                               │  │
│  └──────────────┬─────────────────────────────────────┘  │
│                 │                                         │
│                 v                                         │
│  ┌────────────────────────────────────────────────────┐  │
│  │  Step 2: Calculate Min/Max & Scaling              │  │
│  │  minValue = min(values) × 0.995                   │  │
│  │  maxValue = max(values) × 1.005                   │  │
│  │  valueRange = maxValue - minValue                 │  │
│  └──────────────┬─────────────────────────────────────┘  │
│                 │                                         │
│                 v                                         │
│  ┌────────────────────────────────────────────────────┐  │
│  │  Step 3: Map Data to Canvas Coordinates           │  │
│  │  x = padding.left + (i / (len - 1)) × chartWidth  │  │
│  │  y = padding.top + innerHeight -                  │  │
│  │      ((value - min) / range) × innerHeight        │  │
│  └──────────────┬─────────────────────────────────────┘  │
│                 │                                         │
│                 v                                         │
│  ┌────────────────────────────────────────────────────┐  │
│  │  Step 4: Draw Grid Lines (horizontal)             │  │
│  │  for i in 0..5:                                   │  │
│  │    y = padding.top + (i / 5) × innerHeight        │  │
│  │    ctx.moveTo(padding.left, y)                    │  │
│  │    ctx.lineTo(width - padding.right, y)           │  │
│  └──────────────┬─────────────────────────────────────┘  │
│                 │                                         │
│                 v                                         │
│  ┌────────────────────────────────────────────────────┐  │
│  │  Step 5: Draw Gradient Fill (area under line)     │  │
│  │  ctx.beginPath()                                  │  │
│  │  ctx.moveTo(points[0].x, chartHeight - padding)   │  │
│  │  for point in points: ctx.lineTo(point.x, point.y)│  │
│  │  ctx.lineTo(lastX, chartHeight - padding)         │  │
│  │  ctx.closePath()                                  │  │
│  │  ctx.fill() with rgba color                       │  │
│  └──────────────┬─────────────────────────────────────┘  │
│                 │                                         │
│                 v                                         │
│  ┌────────────────────────────────────────────────────┐  │
│  │  Step 6: Draw Line Path                           │  │
│  │  ctx.moveTo(points[0].x, points[0].y)             │  │
│  │  for point in points: ctx.lineTo(point.x, point.y)│  │
│  │  ctx.stroke() with green/red color                │  │
│  └──────────────┬─────────────────────────────────────┘  │
│                 │                                         │
│                 v                                         │
│  ┌────────────────────────────────────────────────────┐  │
│  │  Step 7: Draw X-axis Labels (dates)               │  │
│  │  Step 8: Draw Hover Point + Crosshair             │  │
│  └────────────────────────────────────────────────────┘  │
│                                                           │
└──────────────────────────────────────────────────────────┘

3.2 Canvas Setup (High DPI)

const drawChart = () => {
  const canvas = canvasRef.current;
  const ctx = canvas.getContext('2d');

  // High DPI support (Retina displays)
  const dpr = window.devicePixelRatio || 1;
  const rect = canvas.getBoundingClientRect();
  canvas.width = rect.width * dpr;
  canvas.height = rect.height * dpr;
  ctx.scale(dpr, dpr);

  const width = rect.width;
  const height = rect.height;
  const padding = { top: 20, right: 20, bottom: 30, left: 60 };

  // Clear canvas
  ctx.clearRect(0, 0, width, height);
};

Why DPR scaling?

  • Retina displays have devicePixelRatio = 2 (or higher)
  • Without scaling, canvas looks blurry on high-DPI screens
  • Scale canvas by DPR, then scale context back for crisp rendering

3.3 Coordinate Mapping

// Get data range
const values = data.map((d) => d.value);
const minValue = Math.min(...values) * 0.995;  // Add 0.5% padding
const maxValue = Math.max(...values) * 1.005;
const valueRange = maxValue - minValue;

// Map data points to canvas coordinates
const points = data.map((d, i) => ({
  x: padding.left + (i / (data.length - 1)) * chartWidth,
  y: padding.top + innerHeight - ((d.value - minValue) / valueRange) * innerHeight,
  data: d,
}));

Coordinate System:

  • Canvas Y-axis: 0 at top, increases downward
  • Chart Y-axis: 0 at bottom, increases upward
  • Formula inverts Y: innerHeight - ((value - min) / range) × height

3.4 Drawing Grid Lines

ctx.strokeStyle = 'rgba(156, 163, 175, 0.2)';
ctx.lineWidth = 1;

const gridLines = 5;
for (let i = 0; i <= gridLines; i++) {
  const y = padding.top + (i / gridLines) * innerHeight;

  // Horizontal grid line
  ctx.beginPath();
  ctx.moveTo(padding.left, y);
  ctx.lineTo(width - padding.right, y);
  ctx.stroke();

  // Y-axis label
  const value = maxValue - (i / gridLines) * valueRange;
  ctx.fillStyle = '#9CA3AF';
  ctx.font = '11px system-ui';
  ctx.textAlign = 'right';
  ctx.fillText(
    `$${value.toLocaleString(undefined, { maximumFractionDigits: 0 })}`,
    padding.left - 8,
    y + 4
  );
}

3.5 Drawing Gradient Fill

const isPositive = data[data.length - 1].value >= data[0].value;
const fillColor = isPositive ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)';

// Draw area fill
ctx.beginPath();
ctx.moveTo(points[0].x, chartHeight - padding.bottom);  // Start at bottom-left
points.forEach((p) => ctx.lineTo(p.x, p.y));  // Follow line
ctx.lineTo(points[points.length - 1].x, chartHeight - padding.bottom);  // Back to bottom
ctx.closePath();
ctx.fillStyle = fillColor;
ctx.fill();

3.6 Drawing Line

const lineColor = isPositive ? '#10B981' : '#EF4444';

ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
points.forEach((p) => ctx.lineTo(p.x, p.y));
ctx.strokeStyle = lineColor;
ctx.lineWidth = 2;
ctx.stroke();

3.7 Hover Interactions

const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
  const canvas = canvasRef.current;
  const rect = canvas.getBoundingClientRect();
  const x = e.clientX - rect.left;

  // Map mouse X to data index
  const padding = { left: 60, right: 20 };
  const chartWidth = rect.width - padding.left - padding.right;
  const relativeX = (x - padding.left) / chartWidth;
  const idx = Math.round(relativeX * (data.length - 1));

  // Clamp to valid range
  const point = data[Math.max(0, Math.min(idx, data.length - 1))];
  setHoveredPoint(point);
};

3.8 Drawing Hover Crosshair

if (hoveredPoint) {
  const idx = data.findIndex((d) => d.date === hoveredPoint.date);
  const point = points[idx];

  // Draw circle at point
  ctx.beginPath();
  ctx.arc(point.x, point.y, 6, 0, Math.PI * 2);
  ctx.fillStyle = lineColor;
  ctx.fill();
  ctx.strokeStyle = '#fff';
  ctx.lineWidth = 2;
  ctx.stroke();

  // Draw vertical dashed line
  ctx.beginPath();
  ctx.moveTo(point.x, padding.top);
  ctx.lineTo(point.x, chartHeight - padding.bottom);
  ctx.strokeStyle = 'rgba(156, 163, 175, 0.5)';
  ctx.lineWidth = 1;
  ctx.setLineDash([4, 4]);
  ctx.stroke();
  ctx.setLineDash([]);
}

3.9 Props Interface

interface PerformanceChartProps {
  portfolioId: string;
  height?: number;  // Default: 300px
}

interface PerformanceDataPoint {
  date: string;         // '2026-01-25'
  value: number;        // Portfolio value
  pnl: number;          // Profit/Loss
  pnlPercent: number;   // P&L percentage
  change: number;       // Daily change
  changePercent: number; // Daily change %
}

type PerformancePeriod = 'week' | 'month' | '3months' | 'year' | 'all';

3.10 Period Selector

const PERIOD_OPTIONS = [
  { value: 'week', label: '7D' },
  { value: 'month', label: '1M' },
  { value: '3months', label: '3M' },
  { value: 'year', label: '1A' },
  { value: 'all', label: 'Todo' },
];

<div className="flex gap-1">
  {PERIOD_OPTIONS.map((option) => (
    <button
      onClick={() => setPeriod(option.value)}
      className={period === option.value ? 'bg-blue-600' : 'bg-gray-100'}
    >
      {option.label}
    </button>
  ))}
</div>

4. Comparación: SVG vs Canvas

Feature SVG (AllocationChart) Canvas (PerformanceChart)
Rendering DOM elements (declarative) Pixel-based (imperative)
Performance Good for < 100 elements Excellent for thousands of points
Interactivity Native (hover, click) Manual (mouse coords → data)
Scaling Vector (infinite zoom) Raster (needs redraw on resize)
Animations CSS transitions Manual redraw
Accessibility Better (DOM structure) Requires ARIA labels
Use Case Static/semi-static shapes Dynamic/real-time data

Why SVG for AllocationChart?

  • Small number of elements (5-10 segments)
  • Native hover tooltips (<title>)
  • Crisp vector rendering at any size

Why Canvas for PerformanceChart?

  • Many data points (30-365)
  • Better performance for redraws on hover
  • Pixel-perfect control over rendering

5. Uso e Integración

5.1 AllocationChart

import AllocationChart from '@/modules/portfolio/components/AllocationChart';

const allocations = [
  { asset: 'BTC', value: 5000, currentPercent: 50 },
  { asset: 'ETH', value: 3000, currentPercent: 30 },
  { asset: 'SOL', value: 2000, currentPercent: 20 },
];

<AllocationChart allocations={allocations} size={250} />

5.2 PerformanceChart

import PerformanceChart from '@/modules/portfolio/components/PerformanceChart';

<PerformanceChart portfolioId="portfolio-123" height={350} />

5.3 Combined Dashboard

function PortfolioDashboard({ portfolioId }: { portfolioId: string }) {
  const { data: portfolio } = usePortfolio(portfolioId);

  return (
    <div className="grid grid-cols-2 gap-6">
      {/* Allocation Donut */}
      <div>
        <h3>Distribución de Activos</h3>
        <AllocationChart
          allocations={portfolio.allocations}
          size={300}
        />
      </div>

      {/* Performance Line Chart */}
      <div>
        <PerformanceChart
          portfolioId={portfolioId}
          height={300}
        />
      </div>
    </div>
  );
}

6. Mejoras Futuras

6.1 AllocationChart Enhancements

  • Interactive selection: Click segment → drill down
  • Animated transitions: Smooth arc transitions on data change
  • Multiple rings: Nested donuts for sub-categories
  • Exploded segments: Separate selected segment

6.2 PerformanceChart Enhancements

  • Zoom/Pan: Pinch to zoom, drag to pan
  • Multiple series: Compare multiple portfolios
  • Annotations: Mark important events (deposits, withdrawals)
  • Export: Download chart as PNG/SVG
  • Real-time updates: WebSocket integration for live data

6.3 Additional Chart Types

  • Bar Chart (Canvas): Monthly returns comparison
  • Heatmap (SVG): Asset correlation matrix
  • Scatter Plot (Canvas): Risk vs Return
  • Candlestick (Canvas): Historical trades

7. Performance Optimization

7.1 Canvas Optimizations

// 1. Debounce mouse move events
const debouncedMouseMove = useMemo(
  () => debounce(handleMouseMove, 16),  // ~60fps
  []
);

// 2. Only redraw on state change
useEffect(() => {
  if (data.length > 0) {
    drawChart();
  }
}, [data, hoveredPoint]);

// 3. Use requestAnimationFrame for smooth animations
const animateChart = () => {
  drawChart();
  requestAnimationFrame(animateChart);
};

7.2 SVG Optimizations

// 1. useMemo for expensive calculations
const segments = useMemo(() => {
  return calculateSegments(allocations);
}, [allocations]);

// 2. CSS instead of re-rendering
<path className="transition-opacity hover:opacity-80" />

8. Testing

Manual Test Cases

  1. AllocationChart:

    • Single asset (100%) → Full circle
    • Two equal assets (50% each) → Two semicircles
    • Many small assets → Visible slices
    • Hover → Tooltip shows correct data
    • Resize → SVG scales properly
  2. PerformanceChart:

    • Positive performance → Green line/fill
    • Negative performance → Red line/fill
    • Hover → Crosshair + tooltip
    • Period change → Data updates
    • Resize → Canvas redraws correctly
    • High DPI → No blur on Retina

9. Referencias


Última actualización: 2026-01-25 Responsable: Frontend Lead