@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.
213 lines (212 loc) • 7.97 kB
JavaScript
const UNKNOWN_ERROR_MESSAGE = 'Unknown error';
export class ImageExporter {
async exportChart(chart, outputPath, options) {
try {
await this.waitForChartRender();
const container = chart.chartElement();
if (!container) {
throw new Error('Chart container not found');
}
const canvas = await this.convertToCanvas(options);
const extension = this.getFileExtension(outputPath);
switch (extension) {
case 'png':
return await this.exportToPNG(canvas);
case 'jpg':
case 'jpeg':
return await this.exportToJPEG(canvas);
case 'svg':
return await this.exportToSVG(container);
default:
throw new Error(`Unsupported format: ${extension}`);
}
}
catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : UNKNOWN_ERROR_MESSAGE
};
}
}
async waitForChartRender() {
return new Promise(resolve => {
setTimeout(resolve, 1000);
});
}
async convertToCanvas(options) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = options.width;
canvas.height = options.height;
ctx.fillStyle = options.backgroundColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
const data = await this.elementToCanvas(options);
ctx.drawImage(data, 0, 0);
return canvas;
}
async elementToCanvas(options) {
const canvas = document.createElement('canvas');
canvas.width = options.width;
canvas.height = options.height;
const ctx = canvas.getContext('2d');
ctx.fillStyle = options.backgroundColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
return canvas;
}
async exportToPNG(canvas) {
try {
const dataUrl = canvas.toDataURL('image/png');
return {
success: true,
dataUrl
};
}
catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : UNKNOWN_ERROR_MESSAGE
};
}
}
async exportToJPEG(canvas) {
try {
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
return {
success: true,
dataUrl
};
}
catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : UNKNOWN_ERROR_MESSAGE
};
}
}
async exportToSVG(element) {
try {
const svgData = this.elementToSVG(element);
return {
success: true,
dataUrl: `data:image/svg+xml;base64,${btoa(svgData)}`
};
}
catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : UNKNOWN_ERROR_MESSAGE
};
}
}
elementToSVG(element) {
try {
const canvas = element.querySelector('canvas');
if (!canvas) {
return this.createFallbackSVG(element);
}
const ctx = canvas.getContext('2d');
if (!ctx) {
return this.createFallbackSVG(element);
}
const width = canvas.width;
const height = canvas.height;
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
let svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`;
svgContent += `<rect width="100%" height="100%" fill="#ffffff"/>`;
const rects = this.convertPixelsToRects(data, width, height);
svgContent += rects.join('');
svgContent += '</svg>';
return svgContent;
}
catch (error) {
console.warn('SVG conversion failed, using fallback:', error);
return this.createFallbackSVG(element);
}
}
convertPixelsToRects(data, width, height) {
const rects = [];
const visited = new Set();
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const index = (y * width + x) * 4;
const r = data[index];
const g = data[index + 1];
const b = data[index + 2];
const a = data[index + 3];
if (a === 0)
continue;
const color = `rgb(${r},${g},${b})`;
const key = `${x},${y}`;
if (visited.has(key))
continue;
const rect = this.findLargestRect(data, width, height, x, y, r, g, b, a, visited);
if (rect) {
const { x: rectX, y: rectY, width: rectWidth, height: rectHeight } = rect;
rects.push(`<rect x="${rectX}" y="${rectY}" width="${rectWidth}" height="${rectHeight}" fill="${color}"/>`);
}
}
}
return rects;
}
findLargestRect(data, width, height, startX, startY, r, g, b, a, visited) {
const maxWidth = this.findMaxWidth(data, width, startX, startY, r, g, b, a, visited);
if (maxWidth === 0)
return null;
const maxHeight = this.findMaxHeight(data, width, height, startX, startY, maxWidth, r, g, b, a, visited);
return { x: startX, y: startY, width: maxWidth, height: maxHeight };
}
findMaxWidth(data, width, startX, startY, r, g, b, a, visited) {
let maxWidth = 0;
for (let x = startX; x < width; x++) {
const index = (startY * width + x) * 4;
if (this.isSameColor(data, index, r, g, b, a)) {
maxWidth++;
visited.add(`${x},${startY}`);
}
else {
break;
}
}
return maxWidth;
}
findMaxHeight(data, width, height, startX, startY, maxWidth, r, g, b, a, visited) {
let maxHeight = 0;
for (let y = startY; y < height; y++) {
if (this.isRowValid(data, width, startX, y, maxWidth, r, g, b, a)) {
maxHeight++;
this.markRowAsVisited(visited, startX, y, maxWidth);
}
else {
break;
}
}
return maxHeight;
}
isSameColor(data, index, r, g, b, a) {
return data[index] === r && data[index + 1] === g && data[index + 2] === b && data[index + 3] === a;
}
isRowValid(data, width, startX, y, maxWidth, r, g, b, a) {
for (let x = startX; x < startX + maxWidth; x++) {
const index = (y * width + x) * 4;
if (!this.isSameColor(data, index, r, g, b, a)) {
return false;
}
}
return true;
}
markRowAsVisited(visited, startX, y, maxWidth) {
for (let x = startX; x < startX + maxWidth; x++) {
visited.add(`${x},${y}`);
}
}
createFallbackSVG(element) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${element.offsetWidth}" height="${element.offsetHeight}" viewBox="0 0 ${element.offsetWidth} ${element.offsetHeight}">
<rect width="100%" height="100%" fill="#ffffff"/>
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle" fill="#666666" font-family="Arial, sans-serif" font-size="14">Chart Export</text>
</svg>`;
}
getFileExtension(path) {
return path.split('.').pop()?.toLowerCase() || 'png';
}
}