UNPKG

@rian8337/osu-strain-graph-generator

Version:

A module for generating strain graph of an osu!standard beatmap.

378 lines (373 loc) 13.5 kB
'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