Last active
February 24, 2026 21:10
-
-
Save Allen-B1/113ae83d65c5dc76be5784b9fd5ebae7 to your computer and use it in GitHub Desktop.
generals.io bar chart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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