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