Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ out/
logs/
bot/
dist/
charts/
node_modules
*.js
*.js
251 changes: 139 additions & 112 deletions scripts/chart.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import * as fs from 'fs';
import * as fs from 'fs';
import axios, { HttpStatusCode } from 'axios';
import SteamAPI from 'steamapi';
import Plotly from 'plotly';
import QuickChart from 'quickchart-js';
import binarySearch from './utils/binarySearch';
import Config from './types/config';
import RankHistory from './types/rankHistory';
import Shape from './types/shape';
import { AnnotationOptions } from 'chartjs-plugin-annotation';

(async () => {
const args = process.argv;
if (args.length !== 5) return;
if (args.length !== 4) return;

const config = JSON.parse(fs.readFileSync('./config.json', { encoding: 'utf8' })) as Config;

const [steamId, seasonNumber] = [process.argv[2], Number(process.argv[3])];
const season = config.seasons.find(s => s.seasonNumber === seasonNumber)!;
const season = config.seasons.find(s => s.seasonNumber === seasonNumber);

if (!season) {
console.error(`Season ${seasonNumber} not found`);
return;
}

const steamApi = new SteamAPI(config.steamApiKey);
const summary = await steamApi.getUserSummary(steamId);
Expand All @@ -26,136 +31,158 @@ import Shape from './types/shape';
}

const rankHistory = rankHistoryRes.data;
const layout = {
title: `${summary.nickname} points in ${season.seasonName}`,
xaxis: {
title: 'Time',
type: 'date',
showgrid: false,
separatethousands: true,
tickangle: 45
},
yaxis: {
exponentformat: 'none',
zeroline: false
},
shapes: new Array(season.seasonRanks.length - 1).fill({}) as Shape[]
};
const trace = {
x: [] as number[],
y: [] as number[],
mode: 'lines+markers',
line: {
shape: 'lines',
width: 3
}
};

for (const tab of rankHistory) {
if (tab.season !== seasonNumber) {
continue;
}
const dailyTotals = new Map(
rankHistory
.filter(({ season }) => season === seasonNumber)
.map(({ score, rating, time }) => [
new Date(time * 1e3).toISOString().slice(0, 10),
seasonNumber === 1 ? score : rating
])
);
let mode: 'lines+markers' | 'lines' = 'lines+markers';

if (trace.x.length > 150) {
trace.mode = 'lines';
}
const dailyPoints = [...dailyTotals.entries()].sort(([dayA], [dayB]) => dayA.localeCompare(dayB));

const labels = dailyPoints.map(([day]) => `${day}T00:00:00.000Z`);
const points = dailyPoints.map(([, total]) => total);

trace.x.push(tab.time * 1e3);
trace.y.push(tab.season === 1 ? tab.score : tab.rating);
if (points.length > 150) {
mode = 'lines';
}

if (trace.y.length === 0) {
if (points.length === 0) {
console.log('User didn\'t play in the given season');
return;
}

const shapes = layout.shapes;
const ranks = season.seasonRanks;
const max = Math.max(...trace.y);
const min = Math.min(...trace.y);

// add shapes for ranks
shapes.forEach((_, index) => {
shapes[index] = {
x0: 0,
x1: 1,
y0: ranks[index].rankPoints,
y1: ranks[index].rankPoints,
xref: 'paper',
line: {
dash: 'dot',
width: '1.5',
color: config.rankColours[index]
},
labels.reverse();
points.reverse();

const max = Math.max(...points);
const min = Math.min(...points);
const annotations: Record<string, AnnotationOptions> = {};

// Add rank lines
season.seasonRanks.forEach((rank, index) => {
if (max < rank.rankPoints || min > rank.rankPoints) {
return;
}

annotations[`rank-${index}`] = {
type: 'line',
scaleID: 'y',
value: rank.rankPoints,
borderColor: config.rankColours[index],
borderWidth: 1.5,
borderDash: [4, 4],
label: {
text: ranks[index].rankName
display: true,
content: rank.rankName,
position: 'end',
backgroundColor: 'rgba(0, 0, 0, 0.65)',
color: '#ffffff'
}
};
});

// remove shapes for ranks beyond user rating
for (let i = shapes.length - 1; i >= 0; i--) {
if (max < shapes[i].y0 || min > shapes[i].y0) {
shapes.splice(i, 1);
}
}

// gray out areas that would have had elo seasons
// Gray out areas that would have had elo seasons
if (season.seasonName === 'Off-season') {
const eloSeasons = [new Date('2018-02-01'), new Date('2019-01-01')];
const timestamps = labels.map(label => Number(new Date(label)));

for (let season of eloSeasons) {
const boundaries = binarySearch(trace.x, Number(season));
if (trace.x.at(-1) === boundaries.at(-1) || trace.x.at(0) === boundaries.at(0)) {
for (const eloSeason of eloSeasons) {
const boundaries = binarySearch(timestamps, Number(eloSeason));

if (timestamps.at(-1) === boundaries.at(-1) || timestamps.at(0) === boundaries.at(0)) {
continue;
}

shapes.push({
type: 'rect',
x0: boundaries.at(0),
x1: boundaries.at(-1),
y0: 0,
y1: 1,
yref: 'paper',
fillcolor: '#d3d3d3',
opacity: 0.2,
line: {
width: '1.2',
color: '#000'
}
})
annotations[`elo-season-${Number(eloSeason)}`] = {
type: 'box',
xScaleID: 'x',
xMin: new Date(boundaries.at(0)!).toISOString(),
xMax: new Date(boundaries.at(-1)!).toISOString(),
Comment on lines +104 to +105
backgroundColor: 'rgba(211, 211, 211, 0.2)',
borderColor: '#000000',
borderWidth: 1.2
};
}
}

trace.x.reverse();
trace.y.reverse();

const figure = {
data: [trace],
layout: layout
};

const imgOpts = {
format: 'png',
width: 1500,
height: 750
};
const chart = new QuickChart();

chart.setWidth(1500);
chart.setHeight(750);
chart.setVersion('4');

chart.setConfig({
type: 'line',
data: {
labels,
datasets: [
{
label: `${summary.nickname} points`,
data: points,
borderWidth: 3,
borderColor: '#3366cc',
backgroundColor: '#3366cc',
pointRadius: mode === 'lines' ? 0 : 3,
pointHoverRadius: mode === 'lines' ? 0 : 4,
tension: 0,
fill: false
}
]
},
options: {
responsive: false,
plugins: {
title: {
display: true,
text: `${summary.nickname} points in ${season.seasonName}`
},
legend: {
display: false
},
annotation: {
annotations
}
},
scales: {
x: {
type: 'time',
title: {
display: true,
text: 'Time'
},
grid: {
display: false
},
ticks: {
maxRotation: 45,
minRotation: 45
}
},
y: {
beginAtZero: false,
ticks: {
callback: (value: any) => `${value}`
},
grid: {
drawTicks: true
}
}
}
}
});

const dir = 'charts';
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}

const plotly = new Plotly(config.plotly.plotlyUsername, config.plotly.plotlyApikey);
plotly.getImage(figure, imgOpts, (error, image) => {
if (error) {
console.error(error);
return;
}
const dir = 'charts';
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
const path = `${dir}/${Date.now()}.png`;
await chart.toFile(path);

const path = `${dir}/${Date.now()}.png`;
const file = fs.createWriteStream(path);
const stream = image.pipe(file);
stream.on('finish', () => console.log(path));
});
})();
console.log(path);
})();
6 changes: 1 addition & 5 deletions scripts/config.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
{
"plotly": {
"plotlyUsername": "<plotly username>",
"plotlyApikey": "<plotly api key>"
},
"seasonNames": [
"Off-season",
"Beta season",
Expand Down Expand Up @@ -191,4 +187,4 @@
],
"steamApiKey": "<steam api key>",
"ddApiRankUrl": "http://api.speedrunners.doubledutchgames.com/GetRankHistory"
}
}
14 changes: 9 additions & 5 deletions scripts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@
"main": "chart.js",
"license": "ISC",
"scripts": {
"build": "tsc --project tsconfig.json --outDir dist"
"start": "tsx chart.ts"
},
"dependencies": {
"axios": "^1.6.3",
"axios": "^1.18.0",
"chart.js": "^4.5.1",
"chartjs-plugin-annotation": "^3.1.0",
"plotly": "^1.0.6",
"steamapi": "^2.4.2"
"quickchart-js": "^3.1.3",
"steamapi": "^3.1.5"
},
"devDependencies": {
"@types/node": "^20.10.6",
"@types/node": "^20.17.0",
"@types/steamapi": "^2.2.5",
"typescript": "^5.3.3"
"tsx": "^4.22.4",
"typescript": "^6.0.3"
}
}
12 changes: 6 additions & 6 deletions scripts/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"esModuleInterop": true
}
}
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"esModuleInterop": true
}
}
6 changes: 1 addition & 5 deletions scripts/types/config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
export default interface Config {
plotly: {
plotlyUsername: string;
plotlyApikey: string;
};
seasons: Season[];
rankColours: string[];
steamApiKey: string;
Expand Down Expand Up @@ -30,4 +26,4 @@ export enum RankNames {
ADVANCED = 'advanced',
BEGINNER = 'beginner',
ENTRY = 'entry',
};
}
Loading