quickchart / lib /charts.js
glutamatt's picture
glutamatt HF Staff
Initial deployment of QuickChart service
ecdd7f6 verified
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,
};