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.

213 lines (212 loc) 7.97 kB
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'; } }