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>
697 lines
26 KiB
Markdown
697 lines
26 KiB
Markdown
# 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
|
||
|