Skip to content

Instantly share code, notes, and snippets.

@Allen-B1
Last active February 24, 2026 21:10
Show Gist options
  • Select an option

  • Save Allen-B1/113ae83d65c5dc76be5784b9fd5ebae7 to your computer and use it in GitHub Desktop.

Select an option

Save Allen-B1/113ae83d65c5dc76be5784b9fd5ebae7 to your computer and use it in GitHub Desktop.
generals.io bar chart
// ==UserScript==
// @name generals.io bar chart
// @namespace http://tampermonkey.net/
// @version 2026-02-24
// @description profile statistics bar chart
// @author person2597
// @match https://generals.io/profiles/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=generals.io
// @grant none
// ==/UserScript==
const MILLIS_PER_DAY = 24*60*60*1000;
function strToDate(str) {
const parts = str.split("-");
return new Date(parts[0] | 0, (parts[1] | 0) - 1, parts[2] | 0);
}
function dateToStr(date) {
return `${date.getFullYear().toString().padStart(4, "0")}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
}
const player = decodeURIComponent(location.pathname.slice(10));
const extid = "person2597-statsv2";
const style = document.createElement('style');
style.textContent = `
#${extid}-root {
min-width: 448px;
height: 264px;
background: #222222; color: #fff;
padding: 0;
position: fixed; left: 0; bottom: 0;
display: flex; flex-direction: column; gap: 16px;
}
.${extid}-tabs {
display: flex;
flex-direction: row;
}
.${extid}-tabs input {
display: none;
}
.${extid}-tabs input:checked + label {
border-bottom: 2px solid teal;
background: #111;
}
.${extid}-tabs label {
font-size: 16px;
flex-grow: 1;
cursor: pointer;
text-align: center;
padding: 8px;
display: inline-block;
}
.${extid}-tabs label:hover {
background: #282828; }
#${extid}-dates {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8px;
}
#${extid}-dates input {
border: 0;
background: teal;
color: #fff;
}
#${extid}-dates button {
cursor: pointer;
border: 0; background: transparent;
box-shadow: none;
font-size: 16px; color: #fff;
padding: 0 8px;
margin: -4px 0;
}
.${extid}-versus {
display: flex;
flex-direction: row;
gap: 8px;
justify-content: center;
align-items: center;
}
.${extid}-versus input {
border: 0;
background: teal;
color: #fff; width: 256px; }
.${extid}-versus span {
width: 32px;
text-align: right; }
#${extid}-total { margin: 0 16px; margin-bottom: -8px; }
#${extid}-bar {
display: flex; flex-direction: row;
background: #111;
margin: 0 16px; margin-bottom: 16px;
height: 48px;
font-size: 16px;
}
#${extid}-bar div {
flex-shrink: 0;
height: 48px;
display: flex; gap: 2px; justify-content: center; flex-direction: column; text-align: center; }
#${extid}-bar div span {
position: relative;
z-index: 1; }
#${extid}-bar div span::before { content: ""; margin-left: -100%; }
#${extid}-bar div span::after { content: ""; margin-right: -100%; }
#${extid}-bar div span:last-child { font-size: 12px; }
.${extid}-bar-1 { background: hsl(40, 75%, 40%); }
.${extid}-bar-2 { background: hsl(0, 0%, 45%); }
.${extid}-bar-3 { background: hsl(20, 35%, 40%); }
.${extid}-bar-L { background: hsl(0, 50%, 40%); }
.${extid}-bar-LL { background: hsl(0, 50%, 32.5%); }
`;
document.head.append(style);
const root = document.createElement("div");
root.id = `${extid}-root`;
document.body.append(root);
const tabs = document.createElement("div");
tabs.className = `${extid}-tabs`;
root.append(tabs);
let activeMode = 'FFA';
for (let label of ["FFA", "1v1", "2v2", "BigTeam", "Custom"]) {
const radio = document.createElement("input");
radio.type = "radio";
radio.id = `${extid}-${label}`;
if (label == "FFA") radio.checked = true;
radio.name = `${extid}-mode`;
tabs.append(radio);
radio.addEventListener("change", (e) => {
activeMode = label;
renderData();
});
const span = document.createElement("label");
span.htmlFor = `${extid}-${label}`;
span.textContent = label;
tabs.append(span);
}
const dates = document.createElement("div");
dates.id = `${extid}-dates`;
root.append(dates);
const fromDate = document.createElement("input");
fromDate.type = "date";
fromDate.value = dateToStr(new Date(Math.min(Date.now() - 2*7*MILLIS_PER_DAY, new Date().setDate(1).valueOf())));
const toDate = document.createElement("input");
toDate.type = "date";
toDate.value = dateToStr(new Date());
fromDate.addEventListener("change", renderData);
toDate.addEventListener("change", renderData);
const arrowLeft = document.createElement("button");
arrowLeft.innerText = "◀";
const arrowRight = document.createElement("button");
arrowRight.innerText = "▶";
arrowLeft.addEventListener("click", () => {
if (toDate.value == fromDate.value) {
let startDate = strToDate(toDate.value);
startDate.setDate(startDate.getDate() - 1);
fromDate.value = toDate.value = dateToStr(startDate);
} else if (strToDate(toDate.value).valueOf() - strToDate(fromDate.value).valueOf() <= 6*28*MILLIS_PER_DAY) {
let startDate = strToDate(toDate.value);
startDate.setDate(1);
startDate.setMonth(startDate.getMonth() - 1);
let endDate = new Date(startDate);
endDate.setMonth(endDate.getMonth() + 1);
endDate.setDate(0);
fromDate.value = dateToStr(startDate);
toDate.value = dateToStr(endDate);
} else {
let startDate = strToDate(toDate.value);
startDate.setDate(1);
startDate.setMonth(0);
startDate.setFullYear(startDate.getFullYear() - 1);
let endDate = new Date(startDate);
endDate.setFullYear(endDate.getFullYear() + 1);
endDate.setDate(0);
fromDate.value = dateToStr(startDate);
toDate.value = dateToStr(endDate);
}
renderData();
});
arrowRight.addEventListener("click", () => {
if (toDate.value == fromDate.value) {
let startDate = strToDate(toDate.value);
startDate.setDate(startDate.getDate() + 1);
fromDate.value = toDate.value = dateToStr(startDate);
} else if (strToDate(toDate.value).valueOf() - strToDate(fromDate.value).valueOf() <= 6*28*MILLIS_PER_DAY) {
let startDate = strToDate(toDate.value);
startDate.setDate(1);
startDate.setMonth(startDate.getMonth() + 1);
let endDate = new Date(startDate);
endDate.setMonth(endDate.getMonth() + 1);
endDate.setDate(0);
fromDate.value = dateToStr(startDate);
toDate.value = dateToStr(endDate);
} else {
let startDate = strToDate(toDate.value);
startDate.setDate(1);
startDate.setMonth(0);
startDate.setFullYear(startDate.getFullYear() + 1);
let endDate = new Date(startDate);
endDate.setFullYear(endDate.getFullYear() + 1);
endDate.setDate(0);
fromDate.value = dateToStr(startDate);
toDate.value = dateToStr(endDate);
}
renderData();
});
dates.append(arrowLeft, fromDate, " to ", toDate, arrowRight);
const versusPanel = document.createElement("div");
versusPanel.className = `${extid}-versus`;
const versusText = document.createElement("span");
const versusInput = document.createElement("input");
versusInput.addEventListener("input", renderData);
versusPanel.append(versusText, versusInput);
root.append(versusPanel);
const sep = document.createElement("div");
sep.style.flexGrow = 1;
root.append(sep);
const total = document.createElement("div");
total.id = `${extid}-total`;
total.innerText = "Loading...";
const barChart = document.createElement("div");
barChart.id = `${extid}-bar`;
root.append(total, barChart);
async function renderData() {
barChart.innerHTML = "";
total.innerText = "Loading...";
if (activeMode == "1v1" || activeMode == "2v2") {
versusPanel.style.display = "flex";
versusText.textContent = {"1v1": 'vs', "2v2": 'with'}[activeMode];
} else {
versusPanel.style.display = "none";
}
let stats = await getStats(strToDate(fromDate.value).valueOf(), strToDate(toDate.value).valueOf() + 24*60*60*1000);
barChart.innerHTML = "";
const width = barChart.getBoundingClientRect().width;
for (let barSect of [1,2,3,"L", "LL"]) {
let elem = document.createElement("div");
elem.classList.add(`${extid}-bar-${barSect}`);
elem.style.width = (width * stats[barSect] / stats.total) + "px";
if (stats[barSect] > 0)
elem.innerHTML = `<span>${Math.round((stats[barSect] / stats.total)*100)}%</span><span>${stats[barSect]}</span>`;
barChart.append(elem);
}
total.innerText = `Total: ${stats.total}`;
}
let data = [];
async function addData() {
let offset = data.length;
const newData = await (await fetch(`https://generals.io/api/replaysForUsername?u=${encodeURIComponent(player)}&offset=${offset}&count=200`)).json();
if (data.length != offset) {
return;
}
data = data.concat(newData);
return newData.length == 0;
}
function modeToType(mode) {
mode = mode.toLowerCase();
return mode == "ffa" ? "classic" : mode;
}
/** returns an educated guess of teammate. if malformed, returns undefined */
function compute2v2Team(ranking, rank) {
if (ranking[0].kills + ranking[1].kills + ranking[2].kills == 2) { // aabb
return { 0: 1, 1: 0, 2: 3, 3: 2 }[rank];
} else if (ranking[0].kills == 2 && ranking[2].kills == 1) { // abba
return { 0: 3, 3: 0, 1: 2, 2: 1 }[rank];
} else if (ranking[0].kills == 1 && ranking[2].kills == 1) { // abab
return { 0: 2, 2: 0, 1: 3, 3: 1 }[rank];
} else if (ranking[0].kills == 2 && ranking[1].kills == 1) { // unknown, default to abab
return { 0: 2, 2: 0, 1: 3, 3: 1 }[rank];
} else { // unknown; 1 total kill; default to aabb
return { 0: 1, 1: 0, 2: 3, 3: 2 }[rank];
}
}
async function getStats(fro, to) {
while (data.length == 0 || data[data.length-1].started > fro) {
if (await addData()) break;
}
let filteredData = data
.filter(g => g.type == modeToType(activeMode))
.filter(g => fro <= g.started && g.started <= to);
if (activeMode == "1v1" && versusInput.value != "") {
filteredData = filteredData.filter(g => versusInput.value != player && (g.ranking[0].currentName == versusInput.value || g.ranking[1].currentName == versusInput.value));
} else if (activeMode == "2v2") {
filteredData = filteredData.filter(g => {
let rank = g.ranking.findIndex(r => r.currentName == player);
let teammate = compute2v2Team(g.ranking, rank);
return teammate != undefined && (g.ranking[teammate].currentName === versusInput.value || versusInput.value === "");
});
}
return filteredData.reduce((acc, g) => {
let rank = g.ranking.findIndex(r => r.currentName == player);
if (activeMode == "1v1") {
if (rank == 0) acc[1] += 1;
else acc.L += 1;
} else if (activeMode == "FFA") {
if (rank == 0) acc[1] += 1;
else if (rank == 1) acc[2] += 1;
else if (rank == 2) acc[3] += 1;
else if (g.ranking[rank].kills) acc.L += 1;
else acc.LL += 1;
} else if (activeMode == "2v2") {
let teammate = compute2v2Team(g.ranking, rank);
if (rank == 0 || teammate == 0) acc[1] += 1;
else if (g.ranking[rank].kills) acc.L += 1;
else acc.LL += 1;
} else if (activeMode == "Custom") {
if (rank == 0) acc[1] += 1;
else if (rank == 1 && g.ranking.length >= 3) acc[2] += 1;
else if (g.ranking[rank].kills) acc.L += 1;
else acc.LL += 1;
} else if (activeMode == "BigTeam") {
if (g.ranking[rank].kills >= 2) acc[3] += 1;
else if (g.ranking[rank].kills >= 1) acc.L += 1;
else acc.LL += 1;
}
acc.total += 1;
return acc;
}, { "1": 0, "2": 0, "3": 0, "LL": 0, "L": 0, "total": 0 });
}
renderData();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment