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

697 lines
26 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.

# 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
```typescript
// 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
```typescript
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
```typescript
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
```tsx
<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
```tsx
<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)
```typescript
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
```typescript
// 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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```tsx
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
```tsx
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
```tsx
import PerformanceChart from '@/modules/portfolio/components/PerformanceChart';
<PerformanceChart portfolioId="portfolio-123" height={350} />
```
### 5.3 Combined Dashboard
```tsx
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
```typescript
// 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
```typescript
// 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
- **AllocationChart:** `apps/frontend/src/modules/portfolio/components/AllocationChart.tsx` (138 líneas)
- **PerformanceChart:** `apps/frontend/src/modules/portfolio/components/PerformanceChart.tsx` (312 líneas)
- **Service:** `apps/frontend/src/services/portfolio.service.ts`
- **US:** `US-PFM-009-custom-charts.md`
- **MDN Canvas API:** https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API
- **MDN SVG Paths:** https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths
---
**Última actualización:** 2026-01-25
**Responsable:** Frontend Lead