@rian8337/osu-strain-graph-generator
Version:
A module for generating strain graph of an osu!standard beatmap.
378 lines (373 loc) • 13.5 kB
JavaScript
'use strict';
var osuBase = require('@rian8337/osu-base');
var canvas = require('canvas');
/**
* Utility to draw a graph with only node-canvas.
*
* Used for creating strain graph of beatmaps.
*/
class Chart {
/**
* The canvas instance of this chart.
*/
canvas;
/**
* The 2D rendering surface for the drawing surface of this chart.
*/
context;
graphWidth;
graphHeight;
minX;
minY;
maxX;
maxY;
unitsPerTickX;
unitsPerTickY;
background;
xLabel;
yLabel;
xValueType;
yValueType;
pointRadius;
padding = 10;
tickSize = 10;
axisColor = "#555";
font = "12pt Calibri";
axisLabelFont = "bold 11pt Calibri";
fontHeight = 12;
baseLabelOffset = 15;
rangeX;
rangeY;
numXTicks;
numYTicks;
x;
y;
width;
height;
scaleX;
scaleY;
/**
* @param values Initializer options for the graph.
*/
constructor(values) {
this.graphWidth = values.graphWidth;
this.graphHeight = values.graphHeight;
this.canvas = canvas.createCanvas(this.graphWidth, this.graphHeight);
this.context = this.canvas.getContext("2d");
this.minX = values.minX;
this.minY = values.minY;
this.maxX = values.maxX;
this.maxY = values.maxY;
this.unitsPerTickX = values.unitsPerTickX;
this.unitsPerTickY = values.unitsPerTickY;
this.background = values.background;
this.xLabel = values.xLabel;
this.yLabel = values.yLabel;
this.xValueType = values.xValueType;
this.yValueType = values.yValueType;
this.pointRadius = Math.max(0, values.pointRadius ?? 1);
// Relationships
this.rangeX = this.maxX - this.minX;
this.rangeY = this.maxY - this.minY;
this.numXTicks = Math.round(this.rangeX / this.unitsPerTickX);
this.numYTicks = Math.round(this.rangeY / this.unitsPerTickY);
this.x = this.getLongestValueWidth() + this.padding * 2;
this.y = this.padding * 2;
this.width = this.canvas.width - this.x - this.padding * 2;
this.height =
this.canvas.height - this.y - this.padding - this.fontHeight;
this.scaleX =
(this.width - (this.xLabel ? this.baseLabelOffset : 0)) /
this.rangeX;
this.scaleY =
(this.height - (this.yLabel ? this.baseLabelOffset : 0)) /
this.rangeY;
// Draw background and X and Y axis tick marks
this.setBackground();
this.drawXAxis(true);
this.drawYAxis(true);
}
/**
* Draws a line graph with specified data, color, and line width.
*
* @param data The data to make the graph.
* @param color The color of the line.
* @param width The width of the line.
*/
drawLine(data, color, width) {
const c = this.context;
c.save();
this.transformContext();
c.lineWidth = width;
c.strokeStyle = c.fillStyle = color;
c.beginPath();
c.moveTo(data[0].x * this.scaleX, data[0].y * this.scaleY);
for (let n = 0; n < data.length; ++n) {
const point = data[n];
// Data segment
c.lineTo(point.x * this.scaleX, point.y * this.scaleY);
c.stroke();
c.closePath();
if (this.pointRadius) {
c.beginPath();
c.arc(point.x * this.scaleX, point.y * this.scaleY, this.pointRadius, 0, 2 * Math.PI, false);
c.fill();
c.closePath();
}
// Position for next segment
c.beginPath();
c.moveTo(point.x * this.scaleX, point.y * this.scaleY);
}
c.restore();
}
/**
* Draws an area graph with specified data and color.
*
* @param data The data to make the graph.
* @param color The color of the area.
*/
drawArea(data, color) {
const c = this.context;
c.save();
this.transformContext();
c.strokeStyle = c.fillStyle = color;
c.beginPath();
data.forEach((d) => c.lineTo(d.x * this.scaleX, d.y * this.scaleY));
c.stroke();
c.lineTo(data.at(-1).x * this.scaleX, 0);
c.lineTo(0, 0);
c.fill();
c.restore();
// Redraw axes since it gets
// overlapped by chart area
if (color !== this.axisColor) {
this.drawXAxis();
this.drawYAxis();
}
}
/**
* Returns a Buffer that represents the graph.
*/
getBuffer() {
return this.canvas.toBuffer();
}
/**
* Draws the X axis of the graph.
*
* @param drawLabel Whether or not to draw the axis label.
*/
drawXAxis(drawLabel) {
const c = this.context;
const labelOffset = this.xLabel ? this.baseLabelOffset : 0;
const yLabelOffset = this.yLabel ? this.baseLabelOffset : 0;
c.save();
if (this.xLabel && drawLabel) {
c.textAlign = "center";
c.font = this.axisLabelFont;
c.fillText(this.xLabel, this.x + this.width / 2, this.y + this.height + labelOffset);
c.restore();
}
c.beginPath();
c.moveTo(this.x + yLabelOffset, this.y + this.height - labelOffset);
c.lineTo(this.x + this.width, this.y + this.height - labelOffset);
c.strokeStyle = this.axisColor;
c.lineWidth = 2;
c.stroke();
// Draw tick marks
for (let n = 0; n < this.numXTicks; ++n) {
c.beginPath();
c.moveTo(((n + 1) * (this.width - yLabelOffset)) / this.numXTicks +
this.x +
yLabelOffset, this.y + this.height - labelOffset);
c.lineTo(((n + 1) * (this.width - yLabelOffset)) / this.numXTicks +
this.x +
yLabelOffset, this.y + this.height - labelOffset - this.tickSize);
c.stroke();
}
// Draw labels
c.font = this.font;
c.fillStyle = "black";
c.textAlign = "center";
c.textBaseline = "middle";
for (let n = 0; n < this.numXTicks; ++n) {
const label = Math.round(((n + 1) * this.maxX) / this.numXTicks);
let stringLabel = label.toString();
switch (this.xValueType) {
case "time":
stringLabel = this.timeString(label);
break;
}
c.save();
c.translate(((n + 1) * (this.width - yLabelOffset)) / this.numXTicks +
this.x +
yLabelOffset, this.y + this.height + this.padding - labelOffset);
c.fillText(stringLabel, 0, 0);
c.restore();
}
c.restore();
}
/**
* Draws the Y axis of the graph.
*
* @param drawLabel Whether or not to draw the axis label.
*/
drawYAxis(drawLabel) {
const c = this.context;
const labelOffset = this.yLabel ? this.baseLabelOffset : 0;
const xLabelOffset = this.xLabel ? this.baseLabelOffset : 0;
c.save();
if (this.yLabel && drawLabel) {
c.textAlign = "center";
c.font = this.axisLabelFont;
c.translate(0, this.graphHeight);
c.rotate(-Math.PI / 2);
c.fillText(this.yLabel, this.y + xLabelOffset + this.height / 2, this.x - labelOffset * 2.5);
c.restore();
}
c.beginPath();
c.moveTo(this.x + labelOffset, this.y);
c.lineTo(this.x + labelOffset, this.y + this.height - xLabelOffset);
c.strokeStyle = this.axisColor;
c.lineWidth = 2;
c.stroke();
c.restore();
// Draw tick marks
for (let n = 0; n < this.numYTicks; ++n) {
c.beginPath();
c.moveTo(this.x + labelOffset, (n * (this.height - xLabelOffset)) / this.numYTicks + this.y);
c.lineTo(this.x + labelOffset + this.tickSize, (n * (this.height - xLabelOffset)) / this.numYTicks + this.y);
c.stroke();
}
// Draw values
c.font = this.font;
c.fillStyle = "black";
c.textAlign = "right";
c.textBaseline = "middle";
for (let n = 0; n < this.numYTicks; ++n) {
const value = Math.round(this.maxY - (n * this.maxY) / this.numYTicks);
c.save();
c.translate(this.x + labelOffset - this.padding, (n * (this.height - xLabelOffset)) / this.numYTicks + this.y);
c.fillText(value.toString(), 0, 0);
c.restore();
}
c.restore();
}
/**
* Transforms the context and move it to the center of the graph.
*/
transformContext() {
const c = this.context;
// Move context to point (0, 0) in graph
c.translate(this.x + (this.yLabel ? this.baseLabelOffset : 0), this.y + this.height - (this.xLabel ? this.baseLabelOffset : 0));
// Invert the Y scale so that it
// increments as we go upwards
c.scale(1, -1);
}
/**
* Gets the longest width from each label text in Y axis.
*/
getLongestValueWidth() {
this.context.font = this.font;
let longestValueWidth = 0;
for (let n = 0; n < this.numYTicks; ++n) {
const value = this.maxY - n * this.unitsPerTickY;
let stringValue = value.toString();
switch (this.yValueType) {
case "time":
stringValue = this.timeString(value);
break;
}
longestValueWidth = Math.max(longestValueWidth, this.context.measureText(stringValue).width);
}
return longestValueWidth;
}
/**
* Sets the background of the graph.
*/
setBackground() {
if (!this.background) {
this.context.globalAlpha = 0.7;
this.context.fillStyle = "#ffffff";
this.context.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.context.fillStyle = "#000000";
return;
}
this.context.globalAlpha = 1;
this.context.drawImage(this.background, 0, 0, this.canvas.width, this.canvas.height);
this.context.globalAlpha = 0.8;
this.context.fillStyle = "#bbbbbb";
this.context.fillRect(0, 0, 900, 250);
this.context.globalAlpha = 1;
this.context.fillStyle = "#000000";
}
/**
* Time string parsing function for axis labels.
*/
timeString(second) {
return new Date(1000 * Math.ceil(second))
.toISOString()
.substr(11, 8)
.replace(/^[0:]+/, "");
}
}
/**
* Generates the strain chart of a difficulty calculator and returns the chart as a buffer.
*
* @param calculator The difficulty calculator to generate the strain graph for.
* @param beatmapsetID The beatmapset ID to get background image from. If omitted, the background will be plain white.
* @param color The color of the graph.
*/
async function getStrainChart(calculator, beatmapsetID, color = "#000000") {
if ([
calculator.strainPeaks.aimWithSliders.length,
calculator.strainPeaks.aimWithoutSliders.length,
calculator.strainPeaks.speed.length,
calculator.strainPeaks.flashlight.length,
].some((v) => v === 0)) {
return null;
}
const sectionLength = 400;
const currentSectionEnd = Math.ceil(calculator.beatmap.hitObjects.objects[0].startTime / sectionLength) * sectionLength;
const strainInformations = new Array(Math.max(calculator.strainPeaks.aimWithSliders.length, calculator.strainPeaks.speed.length, calculator.strainPeaks.flashlight.length));
for (let i = 0; i < strainInformations.length; ++i) {
const aimStrain = calculator.strainPeaks.aimWithSliders[i] ?? 0;
const speedStrain = calculator.strainPeaks.speed[i] ?? 0;
const flashlightStrain = calculator.strainPeaks.flashlight[i] ?? 0;
strainInformations[i] = {
time: (currentSectionEnd + sectionLength * i) / 1000,
strain: calculator.mods.some((m) => m instanceof osuBase.ModFlashlight)
? (aimStrain + speedStrain + flashlightStrain) / 3
: (aimStrain + speedStrain) / 2,
};
}
const maxTime = strainInformations.at(-1).time ??
calculator.objects.at(-1).object.endTime / 1000;
const maxStrain = Math.max(...strainInformations.map((v) => {
return v.strain;
}), 1);
const maxXUnits = 10;
const maxYUnits = 10;
const unitsPerTickX = Math.ceil(maxTime / maxXUnits / 10) * 10;
const unitsPerTickY = Math.ceil(maxStrain / maxYUnits / 20) * 20;
const chart = new Chart({
graphWidth: 900,
graphHeight: 250,
minX: 0,
minY: 0,
maxX: Math.ceil(maxTime / unitsPerTickX) * unitsPerTickX,
maxY: Math.ceil(maxStrain / unitsPerTickY) * unitsPerTickY,
unitsPerTickX,
unitsPerTickY,
background: await canvas.loadImage(`https://assets.ppy.sh/beatmaps/${beatmapsetID}/covers/cover.jpg`).catch(() => {
return undefined;
}),
xLabel: "Time",
yLabel: "Strain",
pointRadius: 0,
xValueType: "time",
});
chart.drawArea(strainInformations.map((v) => new osuBase.Vector2(v.time, v.strain)), color);
return chart.getBuffer();
}
module.exports = getStrainChart;
//# sourceMappingURL=index.js.map