import type { ChartViewModel, ChartTheme, Candle, ChartDimensions, MeasurePoint, ChartIndicator } from './chartTypes'; import { DEFAULT_CHART_THEME, CHART_LAYOUT } from './chartTypes'; import { getIndicatorData, type IndicatorData } from './indicatorUtils'; interface RenderContext { ctx: CanvasRenderingContext2D; viewModel: ChartViewModel; theme: ChartTheme; dpr: number; getXForIndex: (index: number) => number; getYForPrice: (price: number) => number; getYForVolume: (volume: number) => number; indicators: ChartIndicator[]; } /** * Main chart renderer. * Draws all chart elements to a canvas context. */ export function renderChart( ctx: CanvasRenderingContext2D, viewModel: ChartViewModel, theme: ChartTheme = DEFAULT_CHART_THEME, dpr: number, getXForIndex: (index: number) => number, getYForPrice: (price: number) => number, getYForVolume: (volume: number) => number, indicators: ChartIndicator[] = [] ): void { const { chartDimensions } = viewModel; const renderCtx: RenderContext = { ctx, viewModel, theme, dpr, getXForIndex, getYForPrice, getYForVolume, indicators, }; // Clear canvas ctx.fillStyle = theme.background; ctx.fillRect(0, 0, chartDimensions.width * dpr, chartDimensions.height * dpr); // Scale for DPR ctx.save(); ctx.scale(dpr, dpr); // Draw layers in order drawGrid(renderCtx); drawVolumeSection(renderCtx); drawSubChartOverlay(renderCtx); drawCandlesticks(renderCtx); drawIndicators(renderCtx); drawPriceAxis(renderCtx); drawTimeAxis(renderCtx); ctx.restore(); } /** * Renders the crosshair overlay. * Uses a separate canvas for smooth hover updates. */ export function renderCrosshair( ctx: CanvasRenderingContext2D, viewModel: ChartViewModel, mouseX: number, mouseY: number, theme: ChartTheme = DEFAULT_CHART_THEME, dpr: number, getXForIndex: (index: number) => number, getYForPrice: (price: number) => number ): void { const { chartDimensions, hoveredIndex, candles } = viewModel; // Clear overlay ctx.clearRect(0, 0, chartDimensions.width * dpr, chartDimensions.height * dpr); if (hoveredIndex === null || hoveredIndex >= candles.length) { return; } ctx.save(); ctx.scale(dpr, dpr); const candle = candles[hoveredIndex]; const x = getXForIndex(hoveredIndex); const y = mouseY; // Draw vertical line ctx.strokeStyle = theme.crosshair; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(x, chartDimensions.paddingTop); ctx.lineTo(x, chartDimensions.height - chartDimensions.paddingBottom); ctx.stroke(); // Draw horizontal line ctx.beginPath(); ctx.moveTo(chartDimensions.paddingLeft, y); ctx.lineTo(chartDimensions.width - chartDimensions.paddingRight, y); ctx.stroke(); ctx.setLineDash([]); // Draw highlighted candle outline const candleWidth = viewModel.candleLayout.candleWidth; const candleX = x - candleWidth / 2; const openY = getYForPrice(candle.open); const closeY = getYForPrice(candle.close); const highY = getYForPrice(candle.high); const lowY = getYForPrice(candle.low); const bodyTop = Math.min(openY, closeY); const bodyHeight = Math.abs(closeY - openY) || 1; ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2; ctx.strokeRect(candleX - 1, bodyTop - 1, candleWidth + 2, bodyHeight + 2); ctx.restore(); } function drawGrid(renderCtx: RenderContext): void { const { ctx, viewModel, theme } = renderCtx; const { chartDimensions, priceRange } = viewModel; const { paddingLeft, paddingRight, paddingTop, priceChartHeight } = chartDimensions; const chartWidth = chartDimensions.width - paddingLeft - paddingRight; // Draw horizontal price grid lines const priceStep = calculateNiceStep(priceRange.max - priceRange.min, 6); const startPrice = Math.ceil(priceRange.min / priceStep) * priceStep; ctx.strokeStyle = theme.gridLine; ctx.lineWidth = 1; for (let price = startPrice; price <= priceRange.max; price += priceStep) { const y = renderCtx.getYForPrice(price); ctx.beginPath(); ctx.moveTo(paddingLeft, y); ctx.lineTo(chartDimensions.width - paddingRight, y); ctx.stroke(); } // Draw vertical time grid lines (simplified) const visibleCandles = viewModel.visibleEndIndex - viewModel.visibleStartIndex + 1; const gridInterval = Math.ceil(visibleCandles / 8); for (let i = viewModel.visibleStartIndex; i <= viewModel.visibleEndIndex; i += gridInterval) { const x = renderCtx.getXForIndex(i); ctx.beginPath(); ctx.moveTo(x, paddingTop); ctx.lineTo(x, chartDimensions.height - chartDimensions.paddingBottom); ctx.stroke(); } } function drawIndicators(renderCtx: RenderContext): void { const { ctx, viewModel, indicators, getXForIndex, getYForPrice } = renderCtx; const { candles, visibleStartIndex, visibleEndIndex } = viewModel; if (!indicators || indicators.length === 0) return; // Draw price-based indicators (SMA, EMA, Bollinger Bands) for (const indicator of indicators) { if (!indicator.enabled) continue; if (indicator.type === 'rsi' || indicator.type === 'macd' || indicator.type === 'volume') continue; const data = getIndicatorData(indicator, candles); if (data.length === 0) continue; ctx.lineWidth = 1.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; if (indicator.type === 'boll') { // Draw Bollinger Bands (middle, upper, lower) // Draw upper and lower bands with fill ctx.fillStyle = `${indicator.color}15`; ctx.beginPath(); let firstPoint = true; for (const point of data) { if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue; const x = getXForIndex(point.index); const upperY = getYForPrice(point.values[1]); if (firstPoint) { ctx.moveTo(x, upperY); firstPoint = false; } else { ctx.lineTo(x, upperY); } } // Go back drawing lower band for (let i = data.length - 1; i >= 0; i--) { const point = data[i]; if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue; const x = getXForIndex(point.index); const lowerY = getYForPrice(point.values[2]); ctx.lineTo(x, lowerY); } ctx.closePath(); ctx.fill(); // Draw upper band line ctx.strokeStyle = `${indicator.color}80`; ctx.beginPath(); firstPoint = true; for (const point of data) { if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue; const x = getXForIndex(point.index); const y = getYForPrice(point.values[1]); if (firstPoint) { ctx.moveTo(x, y); firstPoint = false; } else { ctx.lineTo(x, y); } } ctx.stroke(); // Draw lower band line ctx.beginPath(); firstPoint = true; for (const point of data) { if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue; const x = getXForIndex(point.index); const y = getYForPrice(point.values[2]); if (firstPoint) { ctx.moveTo(x, y); firstPoint = false; } else { ctx.lineTo(x, y); } } ctx.stroke(); // Draw middle band line ctx.strokeStyle = indicator.color; ctx.beginPath(); firstPoint = true; for (const point of data) { if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue; const x = getXForIndex(point.index); const y = getYForPrice(point.values[0]); if (firstPoint) { ctx.moveTo(x, y); firstPoint = false; } else { ctx.lineTo(x, y); } } ctx.stroke(); } else { // Draw single line indicator (SMA, EMA) ctx.strokeStyle = indicator.color; ctx.beginPath(); let firstPoint = true; for (const point of data) { if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue; const x = getXForIndex(point.index); const y = getYForPrice(point.values[0]); if (firstPoint) { ctx.moveTo(x, y); firstPoint = false; } else { ctx.lineTo(x, y); } } ctx.stroke(); } } } function drawCandlesticks(renderCtx: RenderContext): void { const { ctx, viewModel, theme } = renderCtx; const { candles, visibleStartIndex, visibleEndIndex, candleLayout } = viewModel; const { candleWidth } = candleLayout; const wickWidth = Math.max(1, candleWidth / 6); // Only draw visible candles for (let i = visibleStartIndex; i <= visibleEndIndex; i++) { if (i < 0 || i >= candles.length) continue; const candle = candles[i]; const x = renderCtx.getXForIndex(i); const isBullish = candle.close >= candle.open; const openY = renderCtx.getYForPrice(candle.open); const closeY = renderCtx.getYForPrice(candle.close); const highY = renderCtx.getYForPrice(candle.high); const lowY = renderCtx.getYForPrice(candle.low); // Colors const bodyColor = isBullish ? theme.bullishBody : theme.bearishBody; const wickColor = isBullish ? theme.bullishWick : theme.bearishWick; // Draw wick ctx.fillStyle = wickColor; const wickX = x - wickWidth / 2; ctx.fillRect(wickX, highY, wickWidth, lowY - highY); // Draw body ctx.fillStyle = bodyColor; const bodyTop = Math.min(openY, closeY); const bodyHeight = Math.max(1, Math.abs(closeY - openY)); // Ensure minimum 1px height const bodyX = x - candleWidth / 2; ctx.fillRect(bodyX, bodyTop, candleWidth, bodyHeight); } } function drawVolumeSection(renderCtx: RenderContext): void { const { ctx, viewModel, theme } = renderCtx; const { candles, visibleStartIndex, visibleEndIndex, candleLayout, chartDimensions, volumeRange } = viewModel; const { candleWidth } = candleLayout; const volumeTop = chartDimensions.paddingTop + chartDimensions.priceChartHeight + CHART_LAYOUT.VOLUME_SECTION_GAP; const volumeBottom = chartDimensions.height - chartDimensions.paddingBottom; const volumeHeight = volumeBottom - volumeTop; const volumeMiddle = volumeTop + volumeHeight / 2; // Draw separator line ctx.strokeStyle = theme.gridLineMajor; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(chartDimensions.paddingLeft, volumeTop - CHART_LAYOUT.VOLUME_SECTION_GAP / 2); ctx.lineTo(chartDimensions.width - chartDimensions.paddingRight, volumeTop - CHART_LAYOUT.VOLUME_SECTION_GAP / 2); ctx.stroke(); // Draw center line for volume ctx.strokeStyle = theme.gridLine; ctx.beginPath(); ctx.moveTo(chartDimensions.paddingLeft, volumeMiddle); ctx.lineTo(chartDimensions.width - chartDimensions.paddingRight, volumeMiddle); ctx.stroke(); // Draw volume bars - green goes up, red goes down from center for (let i = visibleStartIndex; i <= visibleEndIndex; i++) { if (i < 0 || i >= candles.length) continue; const candle = candles[i]; const x = renderCtx.getXForIndex(i); const isBullish = candle.close >= candle.open; const barX = x - candleWidth / 2; // Calculate bar height as ratio of max volume, scaled to half the section height const volumeRatio = volumeRange.max > 0 ? candle.volume / volumeRange.max : 0; const barHeight = volumeRatio * (volumeHeight / 2); ctx.fillStyle = isBullish ? theme.volumeBullish : theme.volumeBearish; if (isBullish) { // Green bars grow upward from middle ctx.fillRect(barX, volumeMiddle - barHeight, candleWidth, barHeight); } else { // Red bars grow downward from middle ctx.fillRect(barX, volumeMiddle, candleWidth, barHeight); } } } function drawPriceAxis(renderCtx: RenderContext): void { const { ctx, viewModel, theme } = renderCtx; const { chartDimensions, priceRange } = viewModel; const axisX = chartDimensions.width - chartDimensions.paddingRight + 8; ctx.fillStyle = theme.axisText; ctx.font = '11px ui-monospace, monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; const priceStep = calculateNiceStep(priceRange.max - priceRange.min, 6); const startPrice = Math.ceil(priceRange.min / priceStep) * priceStep; for (let price = startPrice; price <= priceRange.max; price += priceStep) { const y = renderCtx.getYForPrice(price); ctx.fillText(formatPrice(price), axisX, y); } } function drawTimeAxis(renderCtx: RenderContext): void { const { ctx, viewModel, theme } = renderCtx; const { candles, visibleStartIndex, visibleEndIndex, chartDimensions } = viewModel; const axisY = chartDimensions.height - chartDimensions.paddingBottom + 16; ctx.fillStyle = theme.axisText; ctx.font = '11px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; const visibleCandles = visibleEndIndex - visibleStartIndex + 1; const labelInterval = Math.ceil(visibleCandles / 6); for (let i = visibleStartIndex; i <= visibleEndIndex; i += labelInterval) { if (i < 0 || i >= candles.length) continue; const candle = candles[i]; const x = renderCtx.getXForIndex(i); const date = new Date(candle.time); const label = formatTimeLabel(date); ctx.fillText(label, x, axisY); } } function drawSubChartOverlay(renderCtx: RenderContext): void { const { ctx, viewModel, theme, indicators, getXForIndex } = renderCtx; const { candles, visibleStartIndex, visibleEndIndex, chartDimensions, volumeRange, candleLayout } = viewModel; const volumeTop = chartDimensions.paddingTop + chartDimensions.priceChartHeight + CHART_LAYOUT.VOLUME_SECTION_GAP; const volumeHeight = chartDimensions.volumeChartHeight; const chartLeft = chartDimensions.paddingLeft; const chartRight = chartDimensions.width - chartDimensions.paddingRight; const axisX = chartDimensions.paddingLeft - 6; const hasSubChartIndicator = indicators?.some( i => i.enabled && (i.type === 'rsi' || i.type === 'macd') ); // Volume axis labels (left side) ctx.font = '9px ui-monospace, monospace'; ctx.fillStyle = theme.axisText; ctx.textAlign = 'right'; if (hasSubChartIndicator) { // When sub-chart indicator active, show volume inline (top-right) ctx.textBaseline = 'top'; ctx.fillText('Vol ' + formatVolume(volumeRange.max), chartRight, volumeTop + 2); } else { // No sub-chart indicator — volume labels on left axis ctx.textBaseline = 'top'; ctx.fillText(formatVolume(volumeRange.max), axisX, volumeTop + 2); ctx.textBaseline = 'bottom'; ctx.fillText('0', axisX, volumeTop + volumeHeight - 2); } if (!indicators || indicators.length === 0) return; for (const indicator of indicators) { if (!indicator.enabled) continue; if (indicator.type === 'rsi') { const data = getIndicatorData(indicator, candles); if (data.length === 0) continue; const getY = (v: number) => volumeTop + volumeHeight * (1 - v / 100); // Overbought / oversold reference lines (30, 70) ctx.strokeStyle = `${indicator.color}25`; ctx.lineWidth = 1; ctx.setLineDash([3, 3]); for (const level of [30, 70]) { ctx.beginPath(); ctx.moveTo(chartLeft, getY(level)); ctx.lineTo(chartRight, getY(level)); ctx.stroke(); } ctx.setLineDash([]); // RSI line ctx.strokeStyle = indicator.color; ctx.lineWidth = 1.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); let first = true; for (const point of data) { if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue; const x = getXForIndex(point.index); const y = getY(point.values[0]); if (first) { ctx.moveTo(x, y); first = false; } else { ctx.lineTo(x, y); } } ctx.stroke(); // RSI axis labels (left) ctx.font = '9px ui-monospace, monospace'; ctx.textAlign = 'right'; ctx.fillStyle = indicator.color; ctx.textBaseline = 'middle'; ctx.fillText('30', axisX, getY(30)); ctx.fillText('70', axisX, getY(70)); ctx.fillStyle = `${indicator.color}60`; ctx.textBaseline = 'top'; ctx.fillText('100', axisX, getY(100)); ctx.textBaseline = 'bottom'; ctx.fillText('0', axisX, getY(0)); } else if (indicator.type === 'macd') { const data = getIndicatorData(indicator, candles); if (data.length === 0) continue; // Compute range from visible data let dataMin = Infinity, dataMax = -Infinity; for (const point of data) { if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue; for (const v of point.values) { dataMin = Math.min(dataMin, v); dataMax = Math.max(dataMax, v); } } if (dataMin === Infinity) continue; // Symmetric range centered on 0 const absMax = Math.max(Math.abs(dataMin), Math.abs(dataMax)) * 1.15 || 1; const getY = (v: number) => { const ratio = (v + absMax) / (2 * absMax); return volumeTop + volumeHeight * (1 - ratio); }; // Zero line ctx.strokeStyle = `${indicator.color}25`; ctx.lineWidth = 1; ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(chartLeft, getY(0)); ctx.lineTo(chartRight, getY(0)); ctx.stroke(); ctx.setLineDash([]); // Histogram bars const barWidth = Math.max(1, candleLayout.candleWidth * 0.5); const y0 = getY(0); for (const point of data) { if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue; const hist = point.values[2]; const x = getXForIndex(point.index); const yH = getY(hist); ctx.fillStyle = hist >= 0 ? 'rgba(34, 197, 94, 0.3)' : 'rgba(239, 68, 68, 0.3)'; ctx.fillRect(x - barWidth / 2, Math.min(y0, yH), barWidth, Math.abs(yH - y0)); } // MACD line ctx.strokeStyle = indicator.color; ctx.lineWidth = 1.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); let first = true; for (const point of data) { if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue; const x = getXForIndex(point.index); const y = getY(point.values[0]); if (first) { ctx.moveTo(x, y); first = false; } else { ctx.lineTo(x, y); } } ctx.stroke(); // Signal line (amber) ctx.strokeStyle = '#f59e0b'; ctx.lineWidth = 1; ctx.beginPath(); first = true; for (const point of data) { if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue; const x = getXForIndex(point.index); const y = getY(point.values[1]); if (first) { ctx.moveTo(x, y); first = false; } else { ctx.lineTo(x, y); } } ctx.stroke(); // MACD axis labels (left) ctx.font = '9px ui-monospace, monospace'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillStyle = indicator.color; ctx.fillText('0', axisX, y0); } } } function formatVolume(volume: number): string { if (volume >= 1_000_000_000) return (volume / 1_000_000_000).toFixed(1) + 'B'; if (volume >= 1_000_000) return (volume / 1_000_000).toFixed(1) + 'M'; if (volume >= 1_000) return (volume / 1_000).toFixed(1) + 'K'; return volume.toFixed(0); } // Helper functions function calculateNiceStep(range: number, targetSteps: number): number { const roughStep = range / targetSteps; const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep))); const normalized = roughStep / magnitude; let niceNormalized: number; if (normalized < 1.5) niceNormalized = 1; else if (normalized < 3) niceNormalized = 2; else if (normalized < 7) niceNormalized = 5; else niceNormalized = 10; return niceNormalized * magnitude; } function formatPrice(price: number): string { if (price >= 1000) { return price.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); } return price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function formatTimeLabel(date: Date): string { const hours = date.getUTCHours().toString().padStart(2, '0'); const minutes = date.getUTCMinutes().toString().padStart(2, '0'); const month = (date.getUTCMonth() + 1).toString().padStart(2, '0'); const day = date.getUTCDate().toString().padStart(2, '0'); return `${month}/${day} ${hours}:${minutes}`; } /** * Renders measure points and lines on the overlay canvas. */ export function renderMeasure( ctx: CanvasRenderingContext2D, viewModel: ChartViewModel, measurePoints: MeasurePoint[], currentMousePos: { x: number; y: number } | null, theme: ChartTheme, dpr: number, getYForPrice: (price: number) => number ): void { const { chartDimensions } = viewModel; if (measurePoints.length === 0 && !currentMousePos) { return; } ctx.save(); ctx.scale(dpr, dpr); const measureColor = '#3b82f6'; // Blue color for measure tool const measureColorLight = 'rgba(59, 130, 246, 0.3)'; // Draw points measurePoints.forEach((point, index) => { // Draw point circle ctx.beginPath(); ctx.arc(point.x, point.y, 6, 0, Math.PI * 2); ctx.fillStyle = measureColor; ctx.fill(); // Draw inner circle ctx.beginPath(); ctx.arc(point.x, point.y, 3, 0, Math.PI * 2); ctx.fillStyle = '#ffffff'; ctx.fill(); // Draw point label ctx.font = 'bold 11px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillStyle = measureColor; ctx.fillText(`$${point.price.toFixed(2)}`, point.x, point.y - 10); }); // Draw lines between consecutive points if (measurePoints.length >= 2) { ctx.strokeStyle = measureColor; ctx.lineWidth = 2; ctx.setLineDash([6, 4]); for (let i = 0; i < measurePoints.length - 1; i++) { const p1 = measurePoints[i]; const p2 = measurePoints[i + 1]; ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke(); // Draw difference label at midpoint const midX = (p1.x + p2.x) / 2; const midY = (p1.y + p2.y) / 2; const priceDiff = p2.price - p1.price; const percentDiff = ((p2.price - p1.price) / p1.price) * 100; const isPositive = priceDiff >= 0; // Draw label background const labelText = `${isPositive ? '+' : ''}$${priceDiff.toFixed(2)} (${isPositive ? '+' : ''}${percentDiff.toFixed(2)}%)`; ctx.font = 'bold 11px ui-monospace, monospace'; const textWidth = ctx.measureText(labelText).width; const labelPadding = 6; const labelHeight = 20; ctx.fillStyle = isPositive ? 'rgba(34, 197, 94, 0.9)' : 'rgba(239, 68, 68, 0.9)'; ctx.beginPath(); ctx.roundRect( midX - textWidth / 2 - labelPadding, midY - labelHeight / 2, textWidth + labelPadding * 2, labelHeight, 4 ); ctx.fill(); // Draw label text ctx.fillStyle = '#ffffff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(labelText, midX, midY); } ctx.setLineDash([]); } // Draw line from last point to current mouse position (preview) if (measurePoints.length > 0 && currentMousePos) { const lastPoint = measurePoints[measurePoints.length - 1]; ctx.strokeStyle = measureColorLight; ctx.lineWidth = 2; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(lastPoint.x, lastPoint.y); ctx.lineTo(currentMousePos.x, currentMousePos.y); ctx.stroke(); ctx.setLineDash([]); } ctx.restore(); }