Spaces:
Running
Running
| const canvas = require('canvas'); | |
| const deepmerge = require('deepmerge'); | |
| const pattern = require('patternomaly'); | |
| const { CanvasRenderService } = require('chartjs-node-canvas'); | |
| const { fixNodeVmObject } = require('./util'); | |
| const { logger } = require('../logging'); | |
| const { uniqueSvg } = require('./svg'); | |
| // Polyfills | |
| require('canvas-5-polyfill'); | |
| global.CanvasGradient = canvas.CanvasGradient; | |
| // Constants | |
| const ROUND_CHART_TYPES = new Set([ | |
| 'pie', | |
| 'doughnut', | |
| 'polarArea', | |
| 'outlabeledPie', | |
| 'outlabeledDoughnut', | |
| ]); | |
| const BOXPLOT_CHART_TYPES = new Set(['boxplot', 'horizontalBoxplot', 'violin', 'horizontalViolin']); | |
| const MAX_HEIGHT = process.env.CHART_MAX_HEIGHT || 3000; | |
| const MAX_WIDTH = process.env.CHART_MAX_WIDTH || 3000; | |
| const rendererCache = {}; | |
| async function getChartJsForVersion(version) { | |
| if (version && version.startsWith('4')) { | |
| return (await import('chart.js-v4/auto')).Chart; | |
| } | |
| if (version && version.startsWith('3')) { | |
| return require('chart.js-v3'); | |
| } | |
| return require('chart.js'); | |
| } | |
| async function getRenderer(width, height, version, format) { | |
| if (width > MAX_WIDTH) { | |
| throw `Requested width exceeds maximum of ${MAX_WIDTH}`; | |
| } | |
| if (height > MAX_HEIGHT) { | |
| throw `Requested width exceeds maximum of ${MAX_WIDTH}`; | |
| } | |
| const key = `${width}__${height}__${version}__${format}`; | |
| if (!rendererCache[key]) { | |
| const Chart = await getChartJsForVersion(version); | |
| rendererCache[key] = new CanvasRenderService(width, height, undefined, format, () => Chart); | |
| } | |
| return rendererCache[key]; | |
| } | |
| function addColorsPlugin(chart) { | |
| if (chart.options && chart.options.plugins && chart.options.plugins.colorschemes) { | |
| return; | |
| } | |
| chart.options = deepmerge.all([ | |
| {}, | |
| chart.options, | |
| { | |
| plugins: { | |
| colorschemes: { | |
| scheme: 'tableau.Tableau10', | |
| }, | |
| }, | |
| }, | |
| ]); | |
| } | |
| function getGradientFunctions(width, height) { | |
| const getGradientFill = (colorOptions, linearGradient = [0, 0, width, 0]) => { | |
| return function colorFunction() { | |
| const ctx = canvas.createCanvas(20, 20).getContext('2d'); | |
| const gradientFill = ctx.createLinearGradient(...linearGradient); | |
| colorOptions.forEach((options, idx) => { | |
| gradientFill.addColorStop(options.offset, options.color); | |
| }); | |
| return gradientFill; | |
| }; | |
| }; | |
| const getGradientFillHelper = (direction, colors, dimensions = {}) => { | |
| const colorOptions = colors.map((color, idx) => { | |
| return { | |
| color, | |
| offset: idx / (colors.length - 1 || 1), | |
| }; | |
| }); | |
| let linearGradient = [0, 0, dimensions.width || width, 0]; | |
| if (direction === 'vertical') { | |
| linearGradient = [0, 0, 0, dimensions.height || height]; | |
| } else if (direction === 'both') { | |
| linearGradient = [0, 0, dimensions.width || width, dimensions.height || height]; | |
| } | |
| return getGradientFill(colorOptions, linearGradient); | |
| }; | |
| return { | |
| getGradientFill, | |
| getGradientFillHelper, | |
| }; | |
| } | |
| function patternDraw(shapeType, backgroundColor, patternColor, requestedSize) { | |
| return function doPatternDraw() { | |
| const size = Math.min(200, requestedSize) || 20; | |
| // patternomaly requires a document global... | |
| global.document = { | |
| createElement: () => { | |
| return canvas.createCanvas(size, size); | |
| }, | |
| }; | |
| return pattern.draw(shapeType, backgroundColor, patternColor, size); | |
| }; | |
| } | |
| async function renderChartJs( | |
| width, | |
| height, | |
| backgroundColor, | |
| devicePixelRatio, | |
| version, | |
| format, | |
| untrustedChart, | |
| ) { | |
| let chart; | |
| if (typeof untrustedChart === 'string') { | |
| // The chart could contain Javascript - run it in a VM. | |
| try { | |
| const { getGradientFill, getGradientFillHelper } = getGradientFunctions(width, height); | |
| const chartFunction = new Function( | |
| 'getGradientFill', | |
| 'getGradientFillHelper', | |
| 'pattern', | |
| 'Chart', | |
| `return ${untrustedChart}`, | |
| ); | |
| chart = chartFunction( | |
| getGradientFill, | |
| getGradientFillHelper, | |
| { draw: patternDraw }, | |
| getChartJsForVersion(version), | |
| ); | |
| } catch (err) { | |
| logger.error('Input Error', err, untrustedChart); | |
| return Promise.reject(new Error(`Invalid input\n${err}`)); | |
| } | |
| } else { | |
| // The chart is just a simple JSON object. | |
| chart = untrustedChart; | |
| } | |
| // Patch some bugs and issues. | |
| fixNodeVmObject(chart); | |
| chart.options = chart.options || {}; | |
| if (chart.type === 'donut') { | |
| // Fix spelling... | |
| chart.type = 'doughnut'; | |
| } | |
| // TODO(ian): Move special chart type out of this file. | |
| if (chart.type === 'sparkline') { | |
| if (chart.data.datasets.length < 1) { | |
| return Promise.reject(new Error('"sparkline" requres 1 dataset')); | |
| } | |
| chart.type = 'line'; | |
| const dataseries = chart.data.datasets[0].data; | |
| if (!chart.data.labels) { | |
| chart.data.labels = Array(dataseries.length); | |
| } | |
| chart.options.legend = chart.options.legend || { display: false }; | |
| if (!chart.options.elements) { | |
| chart.options.elements = {}; | |
| } | |
| chart.options.elements.line = chart.options.elements.line || { | |
| borderColor: '#000', | |
| borderWidth: 1, | |
| }; | |
| chart.options.elements.point = chart.options.elements.point || { | |
| radius: 0, | |
| }; | |
| if (!chart.options.scales) { | |
| chart.options.scales = {}; | |
| } | |
| let min = Number.POSITIVE_INFINITY; | |
| let max = Number.NEGATIVE_INFINITY; | |
| for (let i = 0; i < dataseries.length; i += 1) { | |
| const dp = dataseries[i]; | |
| min = Math.min(min, dp); | |
| max = Math.max(max, dp); | |
| } | |
| chart.options.scales.xAxes = chart.options.scales.xAxes || [{ display: false }]; | |
| chart.options.scales.yAxes = chart.options.scales.yAxes || [ | |
| { | |
| display: false, | |
| ticks: { | |
| // Offset the min and max slightly so that pixels aren't shaved off | |
| // under certain circumstances. | |
| min: min - min * 0.05, | |
| max: max + max * 0.05, | |
| }, | |
| }, | |
| ]; | |
| } | |
| if (chart.type === 'progressBar') { | |
| chart.type = 'horizontalBar'; | |
| if (chart.data.datasets.length < 1 || chart.data.datasets.length > 2) { | |
| throw new Error('progressBar chart requires 1 or 2 datasets'); | |
| } | |
| let usePercentage = false; | |
| const dataLen = chart.data.datasets[0].data.length; | |
| if (chart.data.datasets.length === 1) { | |
| // Implicit denominator, always out of 100. | |
| usePercentage = true; | |
| chart.data.datasets.push({ data: Array(dataLen).fill(100) }); | |
| } | |
| if (chart.data.datasets[0].data.length !== chart.data.datasets[1].data.length) { | |
| throw new Error('progressBar datasets must have the same size of data'); | |
| } | |
| chart.data.labels = chart.labels || Array.from(Array(dataLen).keys()); | |
| chart.data.datasets[1].backgroundColor = chart.data.datasets[1].backgroundColor || '#fff'; | |
| // Set default border color to first Tableau color. | |
| chart.data.datasets[1].borderColor = chart.data.datasets[1].borderColor || '#4e78a7'; | |
| chart.data.datasets[1].borderWidth = chart.data.datasets[1].borderWidth || 1; | |
| const deepmerge = require('deepmerge'); | |
| chart.options = deepmerge( | |
| { | |
| legend: { display: false }, | |
| scales: { | |
| xAxes: [ | |
| { | |
| ticks: { | |
| display: false, | |
| beginAtZero: true, | |
| }, | |
| gridLines: { | |
| display: false, | |
| drawTicks: false, | |
| }, | |
| }, | |
| ], | |
| yAxes: [ | |
| { | |
| stacked: true, | |
| ticks: { | |
| display: false, | |
| }, | |
| gridLines: { | |
| display: false, | |
| drawTicks: false, | |
| mirror: true, | |
| }, | |
| }, | |
| ], | |
| }, | |
| plugins: { | |
| datalabels: { | |
| color: '#fff', | |
| formatter: (val, ctx) => { | |
| if (usePercentage) { | |
| return `${val}%`; | |
| } | |
| return val; | |
| }, | |
| display: ctx => ctx.datasetIndex === 0, | |
| }, | |
| }, | |
| }, | |
| chart.options, | |
| ); | |
| } | |
| // Choose retina resolution by default. This will cause images to be 2x size | |
| // in absolute terms. | |
| chart.options.devicePixelRatio = devicePixelRatio || 2.0; | |
| // Implement other default options | |
| if ( | |
| chart.type === 'bar' || | |
| chart.type === 'horizontalBar' || | |
| chart.type === 'line' || | |
| chart.type === 'scatter' || | |
| chart.type === 'bubble' | |
| ) { | |
| if (!chart.options.scales) { | |
| // TODO(ian): Merge default options with provided options | |
| chart.options.scales = { | |
| yAxes: [ | |
| { | |
| ticks: { | |
| beginAtZero: true, | |
| }, | |
| }, | |
| ], | |
| }; | |
| } | |
| addColorsPlugin(chart); | |
| } else if (chart.type === 'radar') { | |
| addColorsPlugin(chart); | |
| } else if (ROUND_CHART_TYPES.has(chart.type)) { | |
| addColorsPlugin(chart); | |
| } else if (chart.type === 'scatter') { | |
| addColorsPlugin(chart); | |
| } else if (chart.type === 'bubble') { | |
| addColorsPlugin(chart); | |
| } | |
| if (chart.type === 'line') { | |
| if (chart.data && chart.data.datasets && Array.isArray(chart.data.datasets)) { | |
| chart.data.datasets.forEach(dataset => { | |
| const data = dataset; | |
| // Make line charts straight lines by default. | |
| data.lineTension = data.lineTension || 0; | |
| }); | |
| } | |
| } | |
| chart.options.plugins = chart.options.plugins || {}; | |
| let usingDataLabelsDefaults = false; | |
| if (!chart.options.plugins.datalabels) { | |
| usingDataLabelsDefaults = true; | |
| chart.options.plugins.datalabels = {}; | |
| if (chart.type === 'pie' || chart.type === 'doughnut') { | |
| chart.options.plugins.datalabels = { | |
| display: true, | |
| }; | |
| } else { | |
| chart.options.plugins.datalabels = { | |
| display: false, | |
| }; | |
| } | |
| } | |
| if (ROUND_CHART_TYPES.has(chart.type) || chart.type === 'radialGauge') { | |
| global.Chart = require('chart.js'); | |
| // These requires have side effects. | |
| require('chartjs-plugin-piechart-outlabels'); | |
| if (chart.type === 'doughnut' || chart.type === 'outlabeledDoughnut') { | |
| require('chartjs-plugin-doughnutlabel'); | |
| } | |
| let userSpecifiedOutlabels = false; | |
| chart.data.datasets.forEach(dataset => { | |
| if (dataset.outlabels || chart.options.plugins.outlabels) { | |
| userSpecifiedOutlabels = true; | |
| } else { | |
| // Disable outlabels by default. | |
| dataset.outlabels = { display: false }; | |
| } | |
| }); | |
| if (userSpecifiedOutlabels && usingDataLabelsDefaults) { | |
| // If outlabels are enabled, disable datalabels by default. | |
| chart.options.plugins.datalabels = { | |
| display: false, | |
| }; | |
| } | |
| } | |
| if (chart.options && chart.options.plugins && chart.options.plugins.colorschemes) { | |
| global.Chart = require('chart.js'); | |
| require('chartjs-plugin-colorschemes'); | |
| } | |
| logger.debug('Chart:', JSON.stringify(chart)); | |
| if (version.startsWith('3') || version.startsWith('4')) { | |
| require('chartjs-adapter-moment'); | |
| } | |
| if (!chart.plugins) { | |
| if (version.startsWith('3') || version.startsWith('4')) { | |
| chart.plugins = []; | |
| } else { | |
| const chartAnnotations = require('chartjs-plugin-annotation'); | |
| const chartBoxViolinPlot = require('chartjs-chart-box-and-violin-plot'); | |
| const chartDataLabels = require('chartjs-plugin-datalabels'); | |
| const chartRadialGauge = require('chartjs-chart-radial-gauge'); | |
| chart.plugins = [chartDataLabels, chartAnnotations]; | |
| if (chart.type === 'radialGauge') { | |
| chart.plugins.push(chartRadialGauge); | |
| } | |
| if (BOXPLOT_CHART_TYPES.has(chart.type)) { | |
| chart.plugins.push(chartBoxViolinPlot); | |
| } | |
| } | |
| } | |
| // Background color plugin | |
| chart.plugins.push({ | |
| id: 'background', | |
| beforeDraw: chartInstance => { | |
| if (backgroundColor) { | |
| // Chart.js v3 provides `chartInstance.chart` as `chartInstance` | |
| const chart = chartInstance.chart ? chartInstance.chart : chartInstance; | |
| const { ctx } = chart; | |
| ctx.fillStyle = backgroundColor; | |
| ctx.fillRect(0, 0, chart.width, chart.height); | |
| } | |
| }, | |
| }); | |
| // Pad below legend plugin | |
| if (chart.options.plugins.padBelowLegend) { | |
| chart.plugins.push({ | |
| id: 'padBelowLegend', | |
| beforeInit: (chartInstance, val) => { | |
| global.Chart.Legend.prototype.afterFit = function afterFit() { | |
| this.height = this.height + (Number(val) || 0); | |
| }; | |
| }, | |
| }); | |
| } | |
| const canvasRenderService = await getRenderer(width, height, version, format); | |
| if (format === 'svg') { | |
| // SVG rendering doesn't work asychronously. | |
| return Buffer.from( | |
| uniqueSvg(canvasRenderService.renderToBufferSync(chart, 'image/svg+xml').toString()), | |
| ); | |
| } | |
| return canvasRenderService.renderToBuffer(chart); | |
| } | |
| module.exports = { | |
| renderChartJs, | |
| }; | |