@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
JavaScript
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;
}
}