UNPKG

@neabyte/chart-to-image

Version:

Convert trading charts to images using Node.js canvas with advanced features: 6 chart types, VWAP/EMA/SMA indicators, custom colors, themes, hide elements, scaling, and PNG/JPEG export formats.

314 lines (313 loc) 14.7 kB
export class ChartTypeRenderer { ctx; dimensions; priceRange; config; constructor(ctx, dimensions, priceRange, config) { this.ctx = ctx; this.dimensions = dimensions; this.priceRange = priceRange; this.config = config; } } export class CandlestickRenderer extends ChartTypeRenderer { render(candles) { const candleWidth = Math.max(1, (this.dimensions.chartWidth / candles.length) * 0.8); const spacing = this.dimensions.chartWidth / candles.length; candles.forEach((candle, index) => { const x = this.dimensions.margin.left + index * spacing + spacing / 2; const openY = this.dimensions.margin.top + ((this.priceRange.maxPrice - candle.open) / this.priceRange.priceRange) * this.dimensions.chartHeight; const closeY = this.dimensions.margin.top + ((this.priceRange.maxPrice - candle.close) / this.priceRange.priceRange) * this.dimensions.chartHeight; const highY = this.dimensions.margin.top + ((this.priceRange.maxPrice - candle.high) / this.priceRange.priceRange) * this.dimensions.chartHeight; const lowY = this.dimensions.margin.top + ((this.priceRange.maxPrice - candle.low) / this.priceRange.priceRange) * this.dimensions.chartHeight; const isBullish = candle.close >= candle.open; const color = isBullish ? this.config.customBarColors?.bullish || '#26a69a' : this.config.customBarColors?.bearish || '#ef5350'; this.ctx.strokeStyle = this.config.customBarColors?.wick || '#424242'; this.ctx.lineWidth = 1; this.ctx.beginPath(); this.ctx.moveTo(x, highY); this.ctx.lineTo(x, lowY); this.ctx.stroke(); this.ctx.fillStyle = color; const bodyHeight = Math.max(1, Math.abs(closeY - openY)); const bodyY = Math.min(openY, closeY); this.ctx.fillRect(x - candleWidth / 2, bodyY, candleWidth, bodyHeight); if (this.config.customBarColors?.border) { this.ctx.strokeStyle = this.config.customBarColors.border; this.ctx.lineWidth = 1; this.ctx.strokeRect(x - candleWidth / 2, bodyY, candleWidth, bodyHeight); } }); } } export class LineRenderer extends ChartTypeRenderer { render(candles) { const spacing = this.dimensions.chartWidth / (candles.length - 1); this.ctx.strokeStyle = this.config.customBarColors?.bullish || '#26a69a'; this.ctx.lineWidth = 2; this.ctx.beginPath(); candles.forEach((candle, index) => { const x = this.dimensions.margin.left + index * spacing; const y = this.dimensions.margin.top + ((this.priceRange.maxPrice - candle.close) / this.priceRange.priceRange) * this.dimensions.chartHeight; if (index === 0) { this.ctx.moveTo(x, y); } else { this.ctx.lineTo(x, y); } }); this.ctx.stroke(); } } export class AreaRenderer extends ChartTypeRenderer { render(candles) { const spacing = this.dimensions.chartWidth / (candles.length - 1); const gradient = this.ctx.createLinearGradient(0, this.dimensions.margin.top, 0, this.dimensions.margin.top + this.dimensions.chartHeight); gradient.addColorStop(0, this.config.customBarColors?.bullish || '#26a69a'); gradient.addColorStop(1, (this.config.customBarColors?.bullish || '#26a69a') + '40'); this.ctx.fillStyle = gradient; this.ctx.beginPath(); candles.forEach((candle, index) => { const x = this.dimensions.margin.left + index * spacing; const y = this.dimensions.margin.top + ((this.priceRange.maxPrice - candle.close) / this.priceRange.priceRange) * this.dimensions.chartHeight; if (index === 0) { this.ctx.moveTo(x, y); } else { this.ctx.lineTo(x, y); } }); this.ctx.lineTo(this.dimensions.margin.left + this.dimensions.chartWidth, this.dimensions.margin.top + this.dimensions.chartHeight); this.ctx.lineTo(this.dimensions.margin.left, this.dimensions.margin.top + this.dimensions.chartHeight); this.ctx.closePath(); this.ctx.fill(); this.ctx.strokeStyle = this.config.customBarColors?.bullish || '#26a69a'; this.ctx.lineWidth = 2; this.ctx.beginPath(); candles.forEach((candle, index) => { const x = this.dimensions.margin.left + index * spacing; const y = this.dimensions.margin.top + ((this.priceRange.maxPrice - candle.close) / this.priceRange.priceRange) * this.dimensions.chartHeight; if (index === 0) { this.ctx.moveTo(x, y); } else { this.ctx.lineTo(x, y); } }); this.ctx.stroke(); } } export class HeikinAshiRenderer extends ChartTypeRenderer { calculateHeikinAshi(ohlc) { const ha = []; for (let i = 0; i < ohlc.length; i++) { const candle = ohlc[i]; if (i === 0) { ha.push({ time: candle.time, open: candle.open, high: candle.high, low: candle.low, close: candle.close, volume: candle.volume }); } else { const prev = ha[i - 1]; const haClose = (candle.open + candle.high + candle.low + candle.close) / 4; const haOpen = (prev.open + prev.close) / 2; const haHigh = Math.max(candle.high, haOpen, haClose); const haLow = Math.min(candle.low, haOpen, haClose); ha.push({ time: candle.time, open: haOpen, high: haHigh, low: haLow, close: haClose, volume: candle.volume }); } } return ha; } render(candles) { const haData = this.calculateHeikinAshi(candles); const prices = haData.flatMap(candle => [candle.high, candle.low]); const minPrice = Math.min(...prices); const maxPriceHA = Math.max(...prices); const priceRangeHA = maxPriceHA - minPrice; const candleWidth = Math.max(1, (this.dimensions.chartWidth / haData.length) * 0.8); const spacing = this.dimensions.chartWidth / haData.length; haData.forEach((candle, index) => { const x = this.dimensions.margin.left + index * spacing + spacing / 2; const openY = this.dimensions.margin.top + ((maxPriceHA - candle.open) / priceRangeHA) * this.dimensions.chartHeight; const closeY = this.dimensions.margin.top + ((maxPriceHA - candle.close) / priceRangeHA) * this.dimensions.chartHeight; const highY = this.dimensions.margin.top + ((maxPriceHA - candle.high) / priceRangeHA) * this.dimensions.chartHeight; const lowY = this.dimensions.margin.top + ((maxPriceHA - candle.low) / priceRangeHA) * this.dimensions.chartHeight; const isBullish = candle.close >= candle.open; const color = isBullish ? this.config.customBarColors?.bullish || '#4CAF50' : this.config.customBarColors?.bearish || '#F44336'; this.ctx.strokeStyle = this.config.customBarColors?.wick || '#666666'; this.ctx.lineWidth = 1; this.ctx.beginPath(); this.ctx.moveTo(x, highY); this.ctx.lineTo(x, lowY); this.ctx.stroke(); this.ctx.fillStyle = color; const bodyHeight = Math.max(1, Math.abs(closeY - openY)); const bodyY = Math.min(openY, closeY); this.ctx.fillRect(x - candleWidth / 2, bodyY, candleWidth, bodyHeight); }); } } export class RenkoRenderer extends ChartTypeRenderer { calculateRenko(ohlc, brickSize = 0.01) { const renko = []; let currentPrice = ohlc[0].close; for (let i = 1; i < ohlc.length; i++) { const candle = ohlc[i]; const priceChange = candle.close - currentPrice; const priceChangePercent = Math.abs(priceChange / currentPrice); if (priceChangePercent >= brickSize) { const blocksNeeded = Math.floor(priceChangePercent / brickSize); const direction = priceChange > 0 ? 1 : -1; for (let j = 0; j < blocksNeeded; j++) { const newPrice = currentPrice + direction * brickSize * currentPrice; renko.push({ time: candle.time, open: currentPrice, close: newPrice, high: Math.max(currentPrice, newPrice), low: Math.min(currentPrice, newPrice), direction: direction }); currentPrice = newPrice; } } } return renko; } render(candles) { const renkoData = this.calculateRenko(candles, 0.02); if (renkoData.length === 0) return; const prices = renkoData.flatMap(block => [block.high, block.low]); const minPrice = Math.min(...prices); const maxPriceRenko = Math.max(...prices); const priceRangeRenko = maxPriceRenko - minPrice; const blockWidth = Math.max(10, (this.dimensions.chartWidth / renkoData.length) * 0.9); const spacing = this.dimensions.chartWidth / renkoData.length; renkoData.forEach((block, index) => { const x = this.dimensions.margin.left + index * spacing + spacing / 2; const openY = this.dimensions.margin.top + ((maxPriceRenko - block.open) / priceRangeRenko) * this.dimensions.chartHeight; const closeY = this.dimensions.margin.top + ((maxPriceRenko - block.close) / priceRangeRenko) * this.dimensions.chartHeight; const isUp = block.direction > 0; const color = isUp ? this.config.customBarColors?.bullish || '#26a69a' : this.config.customBarColors?.bearish || '#ef5350'; this.ctx.fillStyle = color; const blockHeight = Math.max(1, Math.abs(closeY - openY)); const blockY = Math.min(openY, closeY); this.ctx.fillRect(x - blockWidth / 2, blockY, blockWidth, blockHeight); if (!isUp) { this.ctx.strokeStyle = color; this.ctx.lineWidth = 2; this.ctx.strokeRect(x - blockWidth / 2, blockY, blockWidth, blockHeight); } }); } } export class LineBreakRenderer extends ChartTypeRenderer { calculateLineBreak(ohlc) { if (ohlc.length === 0) return []; const lineBreakPoints = []; let currentHigh = ohlc[0].high; let currentLow = ohlc[0].low; lineBreakPoints.push({ time: ohlc[0].time, price: ohlc[0].close, direction: 'up' }); for (let i = 1; i < ohlc.length; i++) { const candle = ohlc[i]; const lastPoint = lineBreakPoints[lineBreakPoints.length - 1]; if (candle.high > currentHigh) { lineBreakPoints.push({ time: candle.time, price: candle.high, direction: 'up' }); currentHigh = candle.high; } else if (candle.low < currentLow) { lineBreakPoints.push({ time: candle.time, price: candle.low, direction: 'down' }); currentLow = candle.low; } else { lastPoint.price = candle.close; lastPoint.time = candle.time; } } return lineBreakPoints; } render(candles) { const lineBreakData = this.calculateLineBreak(candles); if (lineBreakData.length < 2) return; const prices = lineBreakData.map(point => point.price); const minPrice = Math.min(...prices); const maxPrice = Math.max(...prices); const priceRange = maxPrice - minPrice; this.ctx.lineWidth = 2; this.ctx.lineCap = 'round'; this.ctx.lineJoin = 'round'; for (let i = 1; i < lineBreakData.length; i++) { const prevPoint = lineBreakData[i - 1]; const currentPoint = lineBreakData[i]; const x1 = this.dimensions.margin.left + ((prevPoint.time - candles[0].time) / (candles[candles.length - 1].time - candles[0].time)) * this.dimensions.chartWidth; const y1 = this.dimensions.margin.top + ((maxPrice - prevPoint.price) / priceRange) * this.dimensions.chartHeight; const x2 = this.dimensions.margin.left + ((currentPoint.time - candles[0].time) / (candles[candles.length - 1].time - candles[0].time)) * this.dimensions.chartWidth; const y2 = this.dimensions.margin.top + ((maxPrice - currentPoint.price) / priceRange) * this.dimensions.chartHeight; const color = currentPoint.direction === 'up' ? this.config.customBarColors?.bullish || '#26a69a' : this.config.customBarColors?.bearish || '#ef5350'; this.ctx.strokeStyle = color; this.ctx.beginPath(); this.ctx.moveTo(x1, y1); this.ctx.lineTo(x2, y2); this.ctx.stroke(); } lineBreakData.forEach(point => { const x = this.dimensions.margin.left + ((point.time - candles[0].time) / (candles[candles.length - 1].time - candles[0].time)) * this.dimensions.chartWidth; const y = this.dimensions.margin.top + ((maxPrice - point.price) / priceRange) * this.dimensions.chartHeight; const color = point.direction === 'up' ? this.config.customBarColors?.bullish || '#26a69a' : this.config.customBarColors?.bearish || '#ef5350'; this.ctx.fillStyle = color; this.ctx.beginPath(); this.ctx.arc(x, y, 3, 0, 2 * Math.PI); this.ctx.fill(); }); } }