# 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 Elements │ │ │ │ │ │ │ └──────────────────────────────────────────────────┘ │ │ │ └────────────────────────────────────────────────────────┘ ``` ### 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 = { 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 {segments.map((segment, index) => ( {segment.asset}: {segment.currentPercent.toFixed(1)}% ($ {segment.value.toLocaleString()}) ))} {/* Center text */} Valor Total ${totalValue.toLocaleString()} ``` ### 2.6 Legend ```tsx
{allocations.map((alloc) => (
{alloc.asset} ({alloc.currentPercent.toFixed(1)}%)
))}
``` --- ## 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) => { 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' }, ];
{PERIOD_OPTIONS.map((option) => ( ))}
``` --- ## 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 (``) - 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