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>
26 KiB
26 KiB
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
-
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
-
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