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.

572 lines (571 loc) 22.4 kB
import { formatPrice, formatTime } from '../renderer/utils.js'; const DEFAULT_FONT = '12px Arial'; export class AxesRenderer { ctx; dimensions; priceRange; config; constructor(ctx, dimensions, priceRange, config) { this.ctx = ctx; this.dimensions = dimensions; this.priceRange = priceRange; this.config = config; } render(ohlc) { this.ctx.strokeStyle = this.config.borderColor || '#2b2b43'; this.ctx.lineWidth = 1; this.ctx.beginPath(); this.ctx.moveTo(this.dimensions.margin.left, this.dimensions.margin.top); this.ctx.lineTo(this.dimensions.margin.left, this.dimensions.margin.top + this.dimensions.chartHeight); this.ctx.stroke(); this.ctx.beginPath(); this.ctx.moveTo(this.dimensions.margin.left, this.dimensions.margin.top + this.dimensions.chartHeight); this.ctx.lineTo(this.dimensions.margin.left + this.dimensions.chartWidth, this.dimensions.margin.top + this.dimensions.chartHeight); this.ctx.stroke(); this.drawPriceLabels(); this.drawTimeLabels(ohlc); } drawPriceLabels() { this.ctx.fillStyle = this.config.textColor || '#ffffff'; this.ctx.font = DEFAULT_FONT; this.ctx.textAlign = 'right'; const numLabels = 5; for (let i = 0; i <= numLabels; i++) { const price = this.priceRange.minPrice + (i / numLabels) * this.priceRange.priceRange; const y = this.dimensions.margin.top + ((this.priceRange.maxPrice - price) / this.priceRange.priceRange) * this.dimensions.chartHeight; const formattedPrice = formatPrice(price); this.ctx.fillText(formattedPrice, this.dimensions.margin.left - 10, y + 4); this.ctx.strokeStyle = this.config.gridColor || '#2b2b43'; this.ctx.lineWidth = 0.5; this.ctx.beginPath(); this.ctx.moveTo(this.dimensions.margin.left - 5, y); this.ctx.lineTo(this.dimensions.margin.left, y); this.ctx.stroke(); } } drawTimeLabels(ohlc) { if (ohlc.length === 0) return; if (this.config.showTimeAxis === false) return; this.ctx.fillStyle = this.config.textColor || '#ffffff'; this.ctx.font = '11px Arial'; this.ctx.textAlign = 'center'; const step = Math.max(1, Math.floor(ohlc.length / 6)); for (let i = 0; i < ohlc.length; i += step) { const candle = ohlc[i]; const x = this.dimensions.margin.left + (i / (ohlc.length - 1)) * this.dimensions.chartWidth; const y = this.dimensions.margin.top + this.dimensions.chartHeight + 20; const timeLabel = formatTime(candle.time); this.ctx.fillText(timeLabel, x, y); this.ctx.strokeStyle = this.config.gridColor || '#2b2b43'; this.ctx.lineWidth = 0.5; this.ctx.beginPath(); this.ctx.moveTo(x, this.dimensions.margin.top + this.dimensions.chartHeight); this.ctx.lineTo(x, this.dimensions.margin.top + this.dimensions.chartHeight + 5); this.ctx.stroke(); } } } export class GridRenderer { ctx; dimensions; config; constructor(ctx, dimensions, config) { this.ctx = ctx; this.dimensions = dimensions; this.config = config; } render() { if (this.config.showGrid === false) return; this.ctx.strokeStyle = this.config.gridColor || '#2b2b43'; this.ctx.lineWidth = 0.5; for (let i = 0; i <= 10; i++) { const x = this.dimensions.margin.left + (i / 10) * this.dimensions.chartWidth; this.ctx.beginPath(); this.ctx.moveTo(x, this.dimensions.margin.top); this.ctx.lineTo(x, this.dimensions.margin.top + this.dimensions.chartHeight); this.ctx.stroke(); } for (let i = 0; i <= 5; i++) { const y = this.dimensions.margin.top + (i / 5) * this.dimensions.chartHeight; this.ctx.beginPath(); this.ctx.moveTo(this.dimensions.margin.left, y); this.ctx.lineTo(this.dimensions.margin.left + this.dimensions.chartWidth, y); this.ctx.stroke(); } } } export class VolumeRenderer { ctx; dimensions; config; constructor(ctx, dimensions, config) { this.ctx = ctx; this.dimensions = dimensions; this.config = config; } render(ohlc) { const volumes = ohlc.map(candle => candle.volume || 0); const maxVolume = Math.max(...volumes); const volumeHeight = this.dimensions.chartHeight * 0.2; const volumeY = this.dimensions.margin.top + this.dimensions.chartHeight + 10; const barWidth = Math.max(1, (this.dimensions.chartWidth / ohlc.length) * 0.8); const spacing = this.dimensions.chartWidth / ohlc.length; ohlc.forEach((candle, index) => { const volume = candle.volume || 0; const volumeBarHeight = (volume / maxVolume) * volumeHeight; const x = this.dimensions.margin.left + index * spacing + spacing / 2; const y = volumeY + volumeHeight - volumeBarHeight; const isBullish = candle.close >= candle.open; const color = isBullish ? (this.config.customBarColors?.bullish || '#26a69a') + '80' : (this.config.customBarColors?.bearish || '#ef5350') + '80'; this.ctx.fillStyle = color; this.ctx.fillRect(x - barWidth / 2, y, barWidth, volumeBarHeight); }); } } export class VWAPRenderer { ctx; dimensions; priceRange; config; constructor(ctx, dimensions, priceRange, config) { this.ctx = ctx; this.dimensions = dimensions; this.priceRange = priceRange; this.config = config; } calculateVWAP(ohlc) { if (ohlc.length === 0) return []; const vwapData = []; let cumulativeVolumePrice = 0; let cumulativeVolume = 0; ohlc.forEach(candle => { const volume = candle.volume || 0; const typicalPrice = (candle.high + candle.low + candle.close) / 3; cumulativeVolumePrice += typicalPrice * volume; cumulativeVolume += volume; const vwap = cumulativeVolume > 0 ? cumulativeVolumePrice / cumulativeVolume : typicalPrice; vwapData.push({ time: candle.time, value: vwap }); }); return vwapData; } render(ohlc) { const vwapData = this.calculateVWAP(ohlc); if (vwapData.length === 0) return; const spacing = this.dimensions.chartWidth / ohlc.length; this.ctx.strokeStyle = this.config.customBarColors?.bullish || '#ff6b6b'; this.ctx.lineWidth = 2; this.ctx.setLineDash([5, 5]); this.ctx.beginPath(); vwapData.forEach(point => { const originalIndex = ohlc.findIndex(candle => candle.time === point.time); if (originalIndex !== -1) { const x = this.dimensions.margin.left + originalIndex * spacing + spacing / 2; const y = this.dimensions.margin.top + ((this.priceRange.maxPrice - point.value) / this.priceRange.priceRange) * this.dimensions.chartHeight; if (originalIndex === 0) { this.ctx.moveTo(x, y); } else { this.ctx.lineTo(x, y); } } }); this.ctx.stroke(); this.ctx.fillStyle = this.config.customBarColors?.bullish || '#ff6b6b'; this.ctx.font = DEFAULT_FONT; this.ctx.textAlign = 'left'; this.ctx.fillText('VWAP', this.dimensions.margin.left + 5, this.dimensions.margin.top + 20); } } export class EMARenderer { ctx; dimensions; priceRange; config; period; constructor(ctx, dimensions, priceRange, config, period) { this.ctx = ctx; this.dimensions = dimensions; this.priceRange = priceRange; this.config = config; this.period = period; } calculateEMA(ohlc) { if (ohlc.length === 0) return []; const emaData = []; const multiplier = 2 / (this.period + 1); let ema = ohlc[0].close; ohlc.forEach((candle, index) => { if (index === 0) { ema = candle.close; } else { ema = (candle.close - ema) * multiplier + ema; } emaData.push({ time: candle.time, value: ema }); }); return emaData; } render(ohlc) { const emaData = this.calculateEMA(ohlc); if (emaData.length === 0) return; const spacing = this.dimensions.chartWidth / ohlc.length; this.ctx.strokeStyle = this.config.customBarColors?.bearish || '#ff6b6b'; this.ctx.lineWidth = 2; this.ctx.setLineDash([]); this.ctx.beginPath(); emaData.forEach(point => { const originalIndex = ohlc.findIndex(candle => candle.time === point.time); if (originalIndex !== -1) { const x = this.dimensions.margin.left + originalIndex * spacing + spacing / 2; const y = this.dimensions.margin.top + ((this.priceRange.maxPrice - point.value) / this.priceRange.priceRange) * this.dimensions.chartHeight; if (originalIndex === 0) { this.ctx.moveTo(x, y); } else { this.ctx.lineTo(x, y); } } }); this.ctx.stroke(); this.ctx.fillStyle = this.config.customBarColors?.bearish || '#ff6b6b'; this.ctx.font = DEFAULT_FONT; this.ctx.textAlign = 'left'; this.ctx.fillText(`EMA(${this.period})`, this.dimensions.margin.left + 5, this.dimensions.margin.top + 20); } } export class SMARenderer { ctx; dimensions; priceRange; config; period; constructor(ctx, dimensions, priceRange, config, period) { this.ctx = ctx; this.dimensions = dimensions; this.priceRange = priceRange; this.config = config; this.period = period; } calculateSMA(ohlc) { if (ohlc.length < this.period) return []; const smaData = []; for (let i = this.period - 1; i < ohlc.length; i++) { let sum = 0; for (let j = i - this.period + 1; j <= i; j++) { sum += ohlc[j].close; } const sma = sum / this.period; smaData.push({ time: ohlc[i].time, value: sma }); } return smaData; } render(ohlc) { const smaData = this.calculateSMA(ohlc); if (smaData.length === 0) return; const spacing = this.dimensions.chartWidth / ohlc.length; this.ctx.strokeStyle = this.config.customBarColors?.bullish || '#4ecdc4'; this.ctx.lineWidth = 2; this.ctx.setLineDash([]); this.ctx.beginPath(); smaData.forEach(point => { const originalIndex = ohlc.findIndex(candle => candle.time === point.time); if (originalIndex !== -1) { const x = this.dimensions.margin.left + originalIndex * spacing + spacing / 2; const y = this.dimensions.margin.top + ((this.priceRange.maxPrice - point.value) / this.priceRange.priceRange) * this.dimensions.chartHeight; if (originalIndex === 0) { this.ctx.moveTo(x, y); } else { this.ctx.lineTo(x, y); } } }); this.ctx.stroke(); this.ctx.fillStyle = this.config.customBarColors?.bullish || '#4ecdc4'; this.ctx.font = DEFAULT_FONT; this.ctx.textAlign = 'left'; this.ctx.fillText(`SMA(${this.period})`, this.dimensions.margin.left + 5, this.dimensions.margin.top + 40); } } export class BollingerBandsRenderer { ctx; dimensions; priceRange; config; period; standardDeviations; constructor(ctx, dimensions, priceRange, config, period, standardDeviations = 2) { this.ctx = ctx; this.dimensions = dimensions; this.priceRange = priceRange; this.config = config; this.period = period; this.standardDeviations = standardDeviations; } calculateBollingerBands(ohlc) { if (ohlc.length < this.period) return []; const bandsData = []; for (let i = this.period - 1; i < ohlc.length; i++) { const prices = []; for (let j = i - this.period + 1; j <= i; j++) { prices.push(ohlc[j].close); } const sma = prices.reduce((sum, price) => sum + price, 0) / this.period; const variance = prices.reduce((sum, price) => sum + Math.pow(price - sma, 2), 0) / this.period; const standardDeviation = Math.sqrt(variance); const upper = sma + this.standardDeviations * standardDeviation; const lower = sma - this.standardDeviations * standardDeviation; bandsData.push({ time: ohlc[i].time, upper, middle: sma, lower }); } return bandsData; } renderBackground(ohlc) { const bandsData = this.calculateBollingerBands(ohlc); if (bandsData.length === 0) return; const spacing = this.dimensions.chartWidth / ohlc.length; if (this.config.bbColors?.background) { this.ctx.fillStyle = this.config.bbColors.background; this.ctx.globalAlpha = this.config.bbColors.backgroundOpacity || 0.1; this.ctx.beginPath(); bandsData.forEach(point => { const originalIndex = ohlc.findIndex(candle => candle.time === point.time); if (originalIndex !== -1) { const x = this.dimensions.margin.left + originalIndex * spacing + spacing / 2; const upperY = this.dimensions.margin.top + ((this.priceRange.maxPrice - point.upper) / this.priceRange.priceRange) * this.dimensions.chartHeight; if (originalIndex === 0) { this.ctx.moveTo(x, upperY); } else { this.ctx.lineTo(x, upperY); } } }); for (let i = bandsData.length - 1; i >= 0; i--) { const point = bandsData[i]; const originalIndex = ohlc.findIndex(candle => candle.time === point.time); if (originalIndex !== -1) { const x = this.dimensions.margin.left + originalIndex * spacing + spacing / 2; const lowerY = this.dimensions.margin.top + ((this.priceRange.maxPrice - point.lower) / this.priceRange.priceRange) * this.dimensions.chartHeight; this.ctx.lineTo(x, lowerY); } } this.ctx.closePath(); this.ctx.fill(); this.ctx.globalAlpha = 1.0; } } renderLines(ohlc) { const bandsData = this.calculateBollingerBands(ohlc); if (bandsData.length === 0) return; const spacing = this.dimensions.chartWidth / ohlc.length; this.ctx.strokeStyle = this.config.bbColors?.upper || this.config.customBarColors?.bearish || '#ff6b6b'; this.ctx.lineWidth = 1; this.ctx.setLineDash([5, 5]); this.ctx.beginPath(); bandsData.forEach(point => { const originalIndex = ohlc.findIndex(candle => candle.time === point.time); if (originalIndex !== -1) { const x = this.dimensions.margin.left + originalIndex * spacing + spacing / 2; const y = this.dimensions.margin.top + ((this.priceRange.maxPrice - point.upper) / this.priceRange.priceRange) * this.dimensions.chartHeight; if (originalIndex === 0) { this.ctx.moveTo(x, y); } else { this.ctx.lineTo(x, y); } } }); this.ctx.stroke(); this.ctx.strokeStyle = this.config.bbColors?.middle || this.config.customBarColors?.bullish || '#4ecdc4'; this.ctx.lineWidth = 2; this.ctx.setLineDash([]); this.ctx.beginPath(); bandsData.forEach(point => { const originalIndex = ohlc.findIndex(candle => candle.time === point.time); if (originalIndex !== -1) { const x = this.dimensions.margin.left + originalIndex * spacing + spacing / 2; const y = this.dimensions.margin.top + ((this.priceRange.maxPrice - point.middle) / this.priceRange.priceRange) * this.dimensions.chartHeight; if (originalIndex === 0) { this.ctx.moveTo(x, y); } else { this.ctx.lineTo(x, y); } } }); this.ctx.stroke(); this.ctx.strokeStyle = this.config.bbColors?.lower || this.config.customBarColors?.bearish || '#ff6b6b'; this.ctx.lineWidth = 1; this.ctx.setLineDash([5, 5]); this.ctx.beginPath(); bandsData.forEach(point => { const originalIndex = ohlc.findIndex(candle => candle.time === point.time); if (originalIndex !== -1) { const x = this.dimensions.margin.left + originalIndex * spacing + spacing / 2; const y = this.dimensions.margin.top + ((this.priceRange.maxPrice - point.lower) / this.priceRange.priceRange) * this.dimensions.chartHeight; if (originalIndex === 0) { this.ctx.moveTo(x, y); } else { this.ctx.lineTo(x, y); } } }); this.ctx.stroke(); this.ctx.fillStyle = this.config.bbColors?.upper || this.config.customBarColors?.bearish || '#ff6b6b'; this.ctx.font = DEFAULT_FONT; this.ctx.textAlign = 'left'; this.ctx.fillText(`BB(${this.period})`, this.dimensions.margin.left + 5, this.dimensions.margin.top + 60); } } export class LevelsRenderer { ctx; dimensions; priceRange; constructor(ctx, dimensions, priceRange) { this.ctx = ctx; this.dimensions = dimensions; this.priceRange = priceRange; } render(levels) { levels.forEach(level => { const y = this.dimensions.margin.top + ((this.priceRange.maxPrice - level.value) / this.priceRange.priceRange) * this.dimensions.chartHeight; this.ctx.strokeStyle = level.color; this.ctx.lineWidth = 1; this.ctx.setLineDash(level.lineStyle === 'dotted' ? [5, 5] : []); this.ctx.beginPath(); this.ctx.moveTo(this.dimensions.margin.left, y); this.ctx.lineTo(this.dimensions.width - this.dimensions.margin.right, y); this.ctx.stroke(); this.ctx.setLineDash([]); if (level.label) { this.ctx.fillStyle = level.color; this.ctx.font = DEFAULT_FONT; this.ctx.textAlign = 'right'; this.ctx.fillText(level.label, this.dimensions.margin.left - 10, y - 5); } }); } } export class TitleRenderer { ctx; width; config; constructor(ctx, width, config) { this.ctx = ctx; this.width = width; this.config = config; } render(title) { if (this.config.showTitle === false) return; this.ctx.fillStyle = this.config.textColor || '#ffffff'; this.ctx.font = 'bold 16px Arial'; this.ctx.textAlign = 'center'; this.ctx.fillText(title, this.width / 2, 30); } } export class WatermarkRenderer { ctx; width; height; constructor(ctx, width, height) { this.ctx = ctx; this.width = width; this.height = height; } render(watermark) { const DEFAULT_POSITION = 'bottom-right'; const DEFAULT_COLOR = '#ffffff'; const DEFAULT_FONT_SIZE = 12; const DEFAULT_OPACITY = 0.3; let text = ''; let position = DEFAULT_POSITION; let color = DEFAULT_COLOR; let fontSize = DEFAULT_FONT_SIZE; let opacity = DEFAULT_OPACITY; if (typeof watermark === 'string') { text = watermark; } else { text = watermark.text; position = watermark.position || DEFAULT_POSITION; color = watermark.color || DEFAULT_COLOR; fontSize = watermark.fontSize || DEFAULT_FONT_SIZE; opacity = watermark.opacity || DEFAULT_OPACITY; } this.ctx.globalAlpha = opacity; this.ctx.fillStyle = color; this.ctx.font = `${fontSize}px Arial`; let x = 0; let y = 0; switch (position) { case 'top-left': x = 20; y = 20; this.ctx.textAlign = 'left'; break; case 'top-right': x = this.width - 20; y = 20; this.ctx.textAlign = 'right'; break; case 'bottom-left': x = 20; y = this.height - 20; this.ctx.textAlign = 'left'; break; case 'bottom-right': default: x = this.width - 20; y = this.height - 20; this.ctx.textAlign = 'right'; break; case 'center': x = this.width / 2; y = this.height / 2; this.ctx.textAlign = 'center'; break; } this.ctx.fillText(text, x, y); this.ctx.globalAlpha = 1; } }