202 lines
5.1 KiB
TypeScript
202 lines
5.1 KiB
TypeScript
|
import { createCanvas, registerFont } from 'canvas';
|
||
|
|
||
|
const width = 1024 + 256;
|
||
|
const height = 512 + 256;
|
||
|
const margin = 128;
|
||
|
|
||
|
const chartAreaX = margin;
|
||
|
const chartAreaY = margin;
|
||
|
const chartAreaWidth = width - (margin * 2);
|
||
|
const chartAreaHeight = height - (margin * 2);
|
||
|
|
||
|
const lineWidth = 16;
|
||
|
const yAxisThickness = 2;
|
||
|
|
||
|
const colors = {
|
||
|
bg: '#434343',
|
||
|
text: '#e0e4cc',
|
||
|
yAxis: '#5a5a5a',
|
||
|
dataset: [
|
||
|
'#ff4e50',
|
||
|
'#c2f725',
|
||
|
'#69d2e7',
|
||
|
'#f38630',
|
||
|
'#f9d423',
|
||
|
]
|
||
|
};
|
||
|
|
||
|
const yAxisTicks = 4;
|
||
|
|
||
|
type Chart = {
|
||
|
title?: string;
|
||
|
datasets: {
|
||
|
title?: string;
|
||
|
data: number[];
|
||
|
}[];
|
||
|
};
|
||
|
|
||
|
export function renderChart(chart: Chart) {
|
||
|
registerFont('./font.ttf', { family: 'CustomFont' });
|
||
|
|
||
|
const canvas = createCanvas(width, height);
|
||
|
const ctx = canvas.getContext('2d');
|
||
|
ctx.antialias = 'default';
|
||
|
|
||
|
ctx.fillStyle = colors.bg;
|
||
|
ctx.beginPath();
|
||
|
ctx.fillRect(0, 0, width, height);
|
||
|
|
||
|
const xAxisCount = chart.datasets[0].data.length;
|
||
|
const serieses = chart.datasets.length;
|
||
|
|
||
|
let lowerBound = Infinity;
|
||
|
let upperBound = -Infinity;
|
||
|
|
||
|
for (let xAxis = 0; xAxis < xAxisCount; xAxis++) {
|
||
|
let v = 0;
|
||
|
for (let series = 0; series < serieses; series++) {
|
||
|
v += chart.datasets[series].data[xAxis];
|
||
|
}
|
||
|
if (v > upperBound) upperBound = v;
|
||
|
if (v < lowerBound) lowerBound = v;
|
||
|
}
|
||
|
|
||
|
// Calculate Y axis scale
|
||
|
const yAxisSteps = niceScale(lowerBound, upperBound, yAxisTicks);
|
||
|
const yAxisStepsMin = yAxisSteps[0];
|
||
|
const yAxisStepsMax = yAxisSteps[yAxisSteps.length - 1];
|
||
|
const yAxisRange = yAxisStepsMax - yAxisStepsMin;
|
||
|
|
||
|
// Draw Y axis
|
||
|
ctx.lineWidth = yAxisThickness;
|
||
|
ctx.lineCap = 'round';
|
||
|
ctx.strokeStyle = colors.yAxis;
|
||
|
for (let i = 0; i < yAxisSteps.length; i++) {
|
||
|
const step = yAxisSteps[yAxisSteps.length - i - 1];
|
||
|
const y = i * (chartAreaHeight / (yAxisSteps.length - 1));
|
||
|
ctx.beginPath();
|
||
|
ctx.lineTo(chartAreaX, chartAreaY + y);
|
||
|
ctx.lineTo(chartAreaX + chartAreaWidth, chartAreaY + y);
|
||
|
ctx.stroke();
|
||
|
|
||
|
ctx.font = '20px CustomFont';
|
||
|
ctx.fillStyle = colors.text;
|
||
|
ctx.fillText(step.toString(), chartAreaX, chartAreaY + y - 8);
|
||
|
}
|
||
|
|
||
|
const newDatasets = [];
|
||
|
|
||
|
for (let series = 0; series < serieses; series++) {
|
||
|
newDatasets.push({
|
||
|
data: []
|
||
|
});
|
||
|
}
|
||
|
|
||
|
for (let xAxis = 0; xAxis < xAxisCount; xAxis++) {
|
||
|
for (let series = 0; series < serieses; series++) {
|
||
|
newDatasets[series].data.push(chart.datasets[series].data[xAxis] / yAxisRange);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const perXAxisWidth = chartAreaWidth / xAxisCount;
|
||
|
|
||
|
let newUpperBound = -Infinity;
|
||
|
|
||
|
for (let xAxis = 0; xAxis < xAxisCount; xAxis++) {
|
||
|
let v = 0;
|
||
|
for (let series = 0; series < serieses; series++) {
|
||
|
v += newDatasets[series].data[xAxis];
|
||
|
}
|
||
|
if (v > newUpperBound) newUpperBound = v;
|
||
|
}
|
||
|
|
||
|
// Draw X axis
|
||
|
ctx.lineWidth = lineWidth;
|
||
|
ctx.lineCap = 'round';
|
||
|
|
||
|
for (let xAxis = 0; xAxis < xAxisCount; xAxis++) {
|
||
|
const xAxisPerTypeHeights = [];
|
||
|
|
||
|
for (let series = 0; series < serieses; series++) {
|
||
|
const v = newDatasets[series].data[xAxis];
|
||
|
const vHeight = (v / newUpperBound) * (chartAreaHeight - ((yAxisStepsMax - upperBound) / yAxisStepsMax * chartAreaHeight));
|
||
|
xAxisPerTypeHeights.push(vHeight);
|
||
|
}
|
||
|
|
||
|
for (let series = serieses - 1; series >= 0; series--) {
|
||
|
ctx.strokeStyle = colors.dataset[series % colors.dataset.length];
|
||
|
|
||
|
let total = 0;
|
||
|
for (let i = 0; i < series; i++) {
|
||
|
total += xAxisPerTypeHeights[i];
|
||
|
}
|
||
|
|
||
|
const height = xAxisPerTypeHeights[series];
|
||
|
|
||
|
const x = chartAreaX + (perXAxisWidth * ((xAxisCount - 1) - xAxis)) + (perXAxisWidth / 2);
|
||
|
|
||
|
const yTop = (chartAreaY + chartAreaHeight) - (total + height);
|
||
|
const yBottom = (chartAreaY + chartAreaHeight) - (total);
|
||
|
|
||
|
ctx.globalAlpha = 1 - (xAxis / xAxisCount);
|
||
|
ctx.beginPath();
|
||
|
ctx.lineTo(x, yTop);
|
||
|
ctx.lineTo(x, yBottom);
|
||
|
ctx.stroke();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return canvas.toBuffer();
|
||
|
}
|
||
|
|
||
|
// https://stackoverflow.com/questions/326679/choosing-an-attractive-linear-scale-for-a-graphs-y-axis
|
||
|
// https://github.com/apexcharts/apexcharts.js/blob/master/src/modules/Scales.js
|
||
|
// This routine creates the Y axis values for a graph.
|
||
|
function niceScale(lowerBound: number, upperBound: number, ticks: number): number[] {
|
||
|
// Calculate Min amd Max graphical labels and graph
|
||
|
// increments. The number of ticks defaults to
|
||
|
// 10 which is the SUGGESTED value. Any tick value
|
||
|
// entered is used as a suggested value which is
|
||
|
// adjusted to be a 'pretty' value.
|
||
|
//
|
||
|
// Output will be an array of the Y axis values that
|
||
|
// encompass the Y values.
|
||
|
const steps = [];
|
||
|
|
||
|
// Determine Range
|
||
|
const range = upperBound - lowerBound;
|
||
|
|
||
|
let tiks = ticks + 1;
|
||
|
// Adjust ticks if needed
|
||
|
if (tiks < 2) {
|
||
|
tiks = 2;
|
||
|
} else if (tiks > 2) {
|
||
|
tiks -= 2;
|
||
|
}
|
||
|
|
||
|
// Get raw step value
|
||
|
const tempStep = range / tiks;
|
||
|
|
||
|
// Calculate pretty step value
|
||
|
const mag = Math.floor(Math.log10(tempStep));
|
||
|
const magPow = Math.pow(10, mag);
|
||
|
const magMsd = (parseInt as any)(tempStep / magPow);
|
||
|
const stepSize = magMsd * magPow;
|
||
|
|
||
|
// build Y label array.
|
||
|
// Lower and upper bounds calculations
|
||
|
const lb = stepSize * Math.floor(lowerBound / stepSize);
|
||
|
const ub = stepSize * Math.ceil(upperBound / stepSize);
|
||
|
// Build array
|
||
|
let val = lb;
|
||
|
while (1) {
|
||
|
steps.push(val);
|
||
|
val += stepSize;
|
||
|
if (val > ub) {
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return steps;
|
||
|
}
|