OsuProfileScoresTimeGraph

Adds graphing for which hours scores were set at

// ==UserScript==
// @name         OsuProfileScoresTimeGraph
// @namespace    https://github.com/Magnus-Cosmos
// @version      1.1.12
// @description  Adds graphing for which hours scores were set at
// @author       Magnus Cosmos
// @match        https://osu.ppy.sh/*
// @require      https://code.jquery.com/jquery-3.6.0.min.js
// @require      https://greasyfork.org/scripts/441005-osuweb/code/OsuWeb.js
// @require      https://greasyfork.org/scripts/441010-osupageobserver/code/OsuPageObserver.js
// @require      https://cdn.jsdelivr.net/npm/chart.js@3.7.1/dist/chart.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.34/moment-timezone-with-data.min.js
// ==/UserScript==

const web = new Web();
const timezones = moment.tz.names();
let localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
let currTimezone = localTimezone;
let selectedTz = null;

function wait(delay) {
    return new Promise(resolve => setTimeout(resolve, delay));
}

function escRegex(text) {
    return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
  }

const flagUrl = code => {
    const baseFileName = code.split('').map((c) => (c.charCodeAt(0) + 127397).toString(16)).join("-");
    return `/assets/images/flags/${baseFileName}.svg`;
};

$(document.head).append($("<style class='circlescript-style'></style>").html(
`
.graph-button {
    display: inline-block;
}

.graph-close {
    background-color: hsl(var(--hsl-red-3)) !important;
}

.graph-close:hover {
    background-color: hsl(var(--hsl-red-2)) !important;
}

.graph-button-container {
    display: table-cell;
    text-align: right;
}

.graph-container {
    margin: 2px 0;
    padding: 10px;
    border-radius: 10px;
    background-color: hsl(var(--hsl-b3));
}

.timezone {
    display: table-cell;
    text-align: center;
    vertical-align: middle;
}

.timezone-label {
    display: inline;
    font-weight: 700;
    color: hsl(var(--hsl-f1));
}

.timezone-input {
    text-align: center;
    font-size: 12px;
}

.graph-heading {
    display: table;
    table-layout: fixed;
    width: 100%;
    margin-top: 10px;
}

.timezones-popup {
    position: absolute;
    right: 50%;
    transform: translateX(50%);
    z-index: 100;
    margin-top: 3px;
}

.timezones-popup .simple-menu--nav2 {
    will-change: auto;
}

.timezones-content {
    max-height: 189px !important;
    min-height: 0px !important;
    -ms-overflow-style: none;
    scrollbar-width: none;
}

.timezones-content::-webkit-scrollbar {
    display: none;
}

.timezones-menu__item--hover {
    background-color: var(--item-hover-bg);
    color: var(--item-hover-colour);
}

.timezones-menu__item--hover:before {
    content: "";
    border-radius: 10px;
    position: absolute;
    top: 7px;
    left: 10px;
    width: 3px;
    height: calc(100% - 14px);
    background-color: var(--stripe-bg);
}

.flag-country--flat {
    image-rendering: -webkit-optimize-contrast;
    image-rendering: crisp-edges;
}

#best-plays-graph {
    display: block;
}
`
));

function staticPage() {
    [this.type, ...this.paths] = location.pathname.split("/").slice(1).map(val => {
        const int = parseInt(val);
        return val == int ? int : val;
    });
}

function getProfileJson() {
    const json = $(".osu-layout--full").attr("data-initial-data");
    if (json) {
        return JSON.parse(json);
    }
    return null;
}

const osuWebObserver = new OsuWebObserver(staticPage, function() {
    switch(this.type) {
        case "users": {
            const profile = getProfileJson();
            const self = currentUser.id;
            const user = profile.user;
            const userIsSelf = self === user.id;
            const mode = this.paths[1] ? this.paths[1] : user.playmode;
            const lazyObserver = new MutationObserver((mutations) => {
               if (user) {
                   graphTimes("best", user, mode, userIsSelf);
                   graphTimes("firsts", user, mode, userIsSelf);
               }
            });
            lazyObserver.observe($(`div[data-page-id="top_ranks"]`).find(".lazy-load")[0], {
                attributes: true,
                attributeFilter: ["class"]
            });
        }
    }
});

const ScoreType = {
    firsts: 2,
    best: 1
}

function filterTimezones() {
    $(this).next().children().first().attr("data-visibility", "visible");
    $(this).next().children().first().removeClass("hidden");
    $(this).next().find(".simple-menu__content").empty();
    const matches = [];
    const input = $(this).val().replace(" ", "_");
    const regex = new RegExp(escRegex(input), "i");
    const prevSelectedTimezone = selectedTz?.attr("data-timezone");
    selectedTz = null;
    if (input.length === 2 && input.toUpperCase() === input) {
        const tzs =  moment.tz.zonesForCountry(input);
        tzs?.forEach(tz => {
            matches.push(tz);
            const isActive = $(this).next().children().first().attr("data-current-timezone") === tz;
            const button = $(
            `<button type="button" class="timezones-menu__item simple-menu__item${isActive ? " simple-menu__item--active" : ""}" data-timezone="${tz}">
                <span class="nav2-locale-item">
                    <span class="nav2-locale-item__flag">
                        <div class="flag-country flag-country--flat" style="background-image: url('${flagUrl(input)}');"></div>
                    </span>
                    ${tz}
                </span>
            </button>`);
            $(this).next().find(".simple-menu__content").append(button);
            if (button.attr("data-timezone") === prevSelectedTimezone) {
                selectedTz = button.addClass("timezones-menu__item--hover");
            }
        });
    } else {
        timezones.forEach(timezone => {
            const match = timezone.match(regex);
            if (match) {
                matches.push(timezone);
                const isActive = $(this).next().children().first().attr("data-current-timezone") === timezone;
                const countries = moment.tz.zone(timezone).countries();
                const countryCode = countries[0];
                const button = $(
                `<button type="button" class="timezones-menu__item simple-menu__item${isActive ? " simple-menu__item--active" : ""}" data-timezone="${timezone}">
                    <span class="nav2-locale-item">
                        <span class="nav2-locale-item__flag">
                            <div class="flag-country flag-country--flat" style="background-image: url('${flagUrl(countryCode ? countryCode : "XX")}');"></div>
                        </span>
                        ${timezone}
                    </span>
                </button>`);
                $(this).next().find(".simple-menu__content").append(button);
                if (button.attr("data-timezone") === prevSelectedTimezone) {
                    selectedTz = button.addClass("timezones-menu__item--hover");
                }
                if (countries.length > 1) {
                    const swapFlagBg = i => {
                        const flag = button.find(".flag-country");
                        const visible = button.parent().parent().attr("data-visibility") === "visible";
                        if (flag && visible) {
                            const code = countries[i];
                            flag.css("background-image", `url('${flagUrl(code)}')`);
                            wait(2000).then(swapFlagBg.bind(this, (i + 1) % countries.length));
                        }
                    }
                    swapFlagBg(Math.floor(Math.random() * countries.length));
                }
            }
        });
    }
    
    if (matches.length === 0) {
        $(this).next().children().first().attr("data-visibility", "hidden");
    } else {
        $(".timezones-menu__item").on("mousedown", function() {
            $(this).parent().parent().parent().prev().val($(this).attr("data-timezone"));
            currTimezone = $(this).attr("data-timezone");
            selectedTz = null;
        });
    }
    return matches;
}

function graphTimes(type, user, mode, userIsSelf) {
    let data = null;
    let matches = [];
    const oh3 = $(`div[data-page-id="top_ranks"]`).find("h3").toArray().filter(el => $(el).css("display") !== "none")[ScoreType[type]];
    const amount = $(oh3).find(".title__count").text();
    if (parseInt(amount) === 0 || !oh3) {
        return;
    }
    currTimezone = localTimezone;
    if (!userIsSelf) {
        const userTimezones =  moment.tz.zonesForCountry(user.country.code);
        if (userTimezones.length > 0) {
            currTimezone = userTimezones[0];
        }
    }
    const h3 = $(oh3).clone().get();
    $(oh3).css("display", "none");
    $(h3).insertAfter(oh3);
    $(h3).wrap(`<div class="graph-heading"></div>`);
    $(`<div class="graph-button-container">
        <button type="button" class="show-more-link graph-button graph-button--${type}">
            <span class="show-more-link__spinner">
                <div class="la-ball-clip-rotate"></div>
            </span>
            <span class="show-more-link__label">
                <span class="show-more-link__label-text">graph</span>
            </span>
        </button>
    </div>`).insertAfter(h3);
    $(`<div class="timezone">
        <input list="timezones" class="account-edit-entry__input timezone-input timezone-input--${type}" value="${currTimezone}" title="Timezone">
        <div class="timezones-popup">
            <div class="simple-menu simple-menu--nav2" data-visibility="hidden" data-current-timezone="${currTimezone}">
                <div class="simple-menu__content timezones-content"></div>
            </div>
        </div>
    </div>`).insertAfter(h3);
    $(`.timezone-input--${type}`).on("focus", function() {
        matches = filterTimezones.call(this);
    });
    $(`.timezone-input--${type}`).on("input", function() {
        matches = filterTimezones.call(this);
    });
    $(`.timezone-input--${type}`).on("change", function() {  
        let count = 0;
        for (const timezone of timezones) {
            const regex = new RegExp(`^${escRegex($(this).val().replace(" ", "_"))}$`, "i");
            const match = timezone.match(regex);
            if (match) {
                count++;
                currTimezone = timezone;
                $(this).next().children().first().attr("data-current-timezone", timezone);
                $(this).val(timezone);
                const chart = Chart.getChart(`graph-${type}`);
                if (chart) {
                    chart.destroy();
                    if (data) {
                        const hidden = $(`.graph-container--${type}`).css("display") === "none";
                        if (!hidden) {
                            graph(`graph-${type}`, hourBins(data));
                        }
                    }
                }
                break;
            }
        }
        if (count === 0) {
            $(this).val($(this).next().children().first().attr("data-current-timezone"));
        }
        if (selectedTz) {
            $(this).next().find(".timezones-menu__item").removeClass("simple-menu__item--active");
            selectedTz.addClass("simple-menu__item--active");
        }
    });
    $(`.timezone-input--${type}`).on("blur", function() {
        $(this).next().children().first().attr("data-visibility", "hidden");
        selectedTz = null;
    });
    $(`.timezone-input--${type}`).keydown(function (e) {
        const tzs = $(this).next().find(".timezones-menu__item");
        if (e.which === 13) {
            if (selectedTz) {
                $(this).next().children().first().attr("data-current-timezone", selectedTz.attr("data-timezone"));
                $(this).val(selectedTz.attr("data-timezone"));
                $(this).blur();
            } else {
                if (matches.length > 0) {
                    $(this).next().children().first().attr("data-current-timezone", matches[0]);
                    $(this).val(matches[0]);
                    $(this).blur();
                }
            }
        } else if (e.which === 38 || e.which === 40) {
            if (selectedTz) {
                selectedTz.removeClass("timezones-menu__item--hover");
                let nextTz;
                if (e.which === 38) {
                    nextTz = selectedTz.prev();
                    if (nextTz.length > 0) {
                        selectedTz = nextTz.addClass("timezones-menu__item--hover");
                    } else {
                        selectedTz = tzs.eq(tzs.length - 1).addClass("timezones-menu__item--hover");
                    }
                } else {
                    nextTz = selectedTz.next();
                    if (nextTz.length > 0) {
                        selectedTz = nextTz.addClass("timezones-menu__item--hover");
                    } else {
                        selectedTz = tzs.eq(0).addClass("timezones-menu__item--hover");
                    }
                }
            } else {
                if (e.which === 38) {
                    selectedTz = tzs.eq(tzs.length - 1).addClass("timezones-menu__item--hover");
                } else {
                    selectedTz = tzs.eq(0).addClass("timezones-menu__item--hover");
                }
            }
            const container = selectedTz.parent().get(0);
            const selectedEl = selectedTz.get(0);
            container.scrollTop = selectedEl.offsetTop - (container.clientHeight + 10) + selectedEl.clientHeight;
            return false;
        }
    });
    $(`div[data-page-id="top_ranks"]`).find(".show-more-link--profile-page").css("margin-bottom", "5px");
    $(`.graph-button--${type}`).on("click", function() {
        $(this).children().first().css("display", "inline-flex");
        $(this).children().last().css("visibility", "hidden");
        $(this).prop("disabled", true);
        const successFn = scores => {
            data = scores;
            const bins = hourBins(data);
            $(`<div class="graph-container graph-container--${type}"></div>`).append(`<canvas id="graph-${type}"></canvas>`).insertAfter($(this).parent().parent());
            graph(`graph-${type}`, bins);
            $(this).children().first().removeAttr("style");
            $(this).children().last().removeAttr("style");
            $(this).find(".show-more-link__label-text").text("close graph");
            $(this).addClass("graph-close");
            $(this).unbind("click");
            $(this).prop("disabled", false);
            $(this).on("click", function() {
                const hidden = $(`.graph-container--${type}`).css("display") === "none";
                if (hidden) {
                    $(this).find(".show-more-link__label-text").text("close graph");
                    $(this).addClass("graph-close");
                    $(`.graph-container--${type}`).show();
                    const chart = Chart.getChart(`graph-${type}`);
                    if (!chart && data) {
                        graph(`graph-${type}`, hourBins(data));
                    }
                } else {
                    $(this).find(".show-more-link__label-text").text("show graph");
                    $(this).removeClass("graph-close");
                    $(`.graph-container--${type}`).hide();
                } 
            });
        };
        const errorFn = err => {
            $(this).children().first().removeAttr("style");
            $(this).children().last().removeAttr("style");
            $(this).prop("disabled", false);
            console.log(err);
        }
        switch (type) {
            case "firsts": {
                getFirstPlaces(user, [], mode, 0, successFn, errorFn);
                break;
            }
            case "best": {
                web.get(`/users/${user.id}/scores/best`, { mode: mode, limit: 100 }, res => {
                    res.json().then(successFn).catch(errorFn);
                }, errorFn, { credentials: "include" });
                break;
            }
        }
    });
}

function getFirstPlaces(user, scores, mode, offset, successFn, errorFn, tries = 0) {
    web.get(`/users/${user.id}/scores/firsts`, { mode: mode, limit: 100, offset: offset }, res => {
        res.json().then(data => {
            if (data.length === 0) {
                successFn(scores);
                return;
            }
            scores.push(...data);
            getFirstPlaces(user, scores, mode, offset + 100, successFn, errorFn);
        }).catch(err => {
            if (tries > 8) {
                errorFn(err);
                return;
            }
            wait(5000 * (tries + 1)).then(() => getFirstPlaces(user, scores, mode, offset, successFn, errorFn, tries + 1));
        });
    }, errorFn, { credentials: "include" });
}

function hourBins(scores) {
    const bins = scores.reduce((obj, score) => {
        let hour = null;
        if (timezones.includes(currTimezone)) {
            const date = moment(score.ended_at).tz(currTimezone);
            hour = date.hours();
        } else {
            const date = new Date(score.ended_at);
            hour = date.getHours();
        }
        obj[hour]++;
        return obj;
    }, Object.fromEntries([...Array(24).keys()].map(k => [k, 0])));
    return Object.entries(bins).map(([k, v]) => {
        return {
            x: k,
            y: v
        };
    });
}


function graph(id, data) {
    const ctx = document.getElementById(id).getContext("2d");
    const font = "Torus,Inter,Helvetica Neue,Tahoma,Arial,Hiragino Kaku Gothic ProN,Meiryo,Microsoft YaHei,Apple SD Gothic Neo,sans-serif";
    const chartAreaBorder = {
        id: "chartAreaBorder",
        beforeDraw(chart, args, options) {
            const {ctx, chartArea: {left, top, width, height}} = chart;
            ctx.save();
            ctx.strokeStyle = options.borderColor;
            ctx.lineWidth = options.borderWidth;
            ctx.setLineDash(options.borderDash || []);
            ctx.lineDashOffset = options.borderDashOffset;
            ctx.strokeRect(left, top, width, height);
            ctx.restore();
        }
    };
    return new Chart(ctx, {
        type: "bar",
        data: {
            datasets: [{
                data: data,
                backgroundColor: [
                    "rgba(31, 119, 180, 0.5)",
                ],
                barPercentage: 1,
                categoryPercentage: 0.96,
                borderRadius: 4,
                borderWidth: 1
            }]
        },
        options: {
            responsive: true,
            layout: {
                padding: {
                    right: 10
                }
            },
            scales: {
                x: {
                    title: {
                        display: true,
                        text: "Hour of the day",
                        color: "rgba(255,255,255,0.6)",
                        font: {
                            family: font,
                            weight: 400,
                            size: 14
                        }
                    },
                    ticks: {
                        offset: false,
                        color: "rgba(255,255,255,0.4)",
                        font: {
                            family: font,
                            weight: 100
                        }
                    },
                    offset: true,
                    grid: {
                        offset: true,
                        drawBorder: false
                    }
                },
                y: {
                    title: {
                        display: true,
                        text: "# of plays set",
                        color: "rgba(255,255,255,0.6)",
                        font: {
                            family: font,
                            weight: 400,
                            size: 14
                        }
                    },
                    ticks: {
                        color: "rgba(255,255,255,0.4)",
                        font: {
                            family: font,
                            weight: 100
                        },
                        callback: (label, index, labels) => {
                            if (Math.floor(label) === label) {
                                return label;
                            }
                        }
                    },
                    grid: {
                        drawBorder: false
                    }
                }
            },
            plugins: {
                legend: {
                    display: false
                },
                tooltip: {
                    enabled: false
                },
                chartAreaBorder: {
                    borderColor: "rgba(0,0,0,0.25)",
                    borderWidth: 1
                }
            }
        },
        plugins: [chartAreaBorder]
    });
}