AtCoder Perf Graph (forked)

レーティンググラフにパフォーマンスのグラフを重ねて表示します AHCにも対応しています

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

Advertisement:

// ==UserScript==
// @name            AtCoder Perf Graph (forked)
// @name:en         AtCoder Perf Graph (forked)
// @namespace       https://kirameku.f5.si/
// @version         1.1.4
// @description     レーティンググラフにパフォーマンスのグラフを重ねて表示します AHCにも対応しています
// @description:en  Displays a performance graph overlaid on the rating graph. It also supports AHC.
// @author          kirameku
// @match           *://atcoder.jp/users*
// @exclude         *://atcoder.jp/users/*?graph=rank
// @exclude         *://atcoder.jp/users/*?graph=dist
// @exclude         *://atcoder.jp/users/*/history*
// @exclude         *://atcoder.jp/users/*/json*
// @grant           GM_setValue
// @grant           GM_getValue
// @run-at          document-start
// @license         MIT
// ==/UserScript==

(function() {
    'use strict';

    console.log("[PerfGraph] スクリプトが起動しました。");

    // 公式のリスナー登録を横取りして無効化するフック
    const originalAddEventListener = window.addEventListener;
    window.addEventListener = function(type, listener, options) {
        if (type === 'DOMContentLoaded' && listener && listener.toString().includes('init()')) {
            console.log("[PerfGraph] 公式の自動描画イベントリスナーをブロックしました。");
            return;
        }
        return originalAddEventListener.apply(this, arguments);
    };

    // グローバル変数 'init' のフック(公式の init を完全に無効化し、カスタム init に差し替える)
    Object.defineProperty(window, 'init', {
        configurable: true,
        enumerable: true,
        get: () => {
            // 公式が呼び出そうとした場合はカスタム関数を返し、それ以外は空関数でガードする
            return window.__customInit || function() {};
        },
        set: (val) => {
            // 公式側が init を上書きしようとしても無視する
            console.log("[PerfGraph] 公式による init の上書きを防止しました。");
        }
    });

    // ページ埋め込みの rating_history が有効な配列になるまで待つ
    function waitOfRatingHistory() {
        return new Promise((resolve) => {
            let attempts = 0;
            const timer = setInterval(() => {
                attempts++;
                let history = window.rating_history || (typeof unsafeWindow !== 'undefined' ? unsafeWindow.rating_history : undefined);

                if (typeof history !== 'undefined' && Array.isArray(history) && history.length > 0) {
                    clearInterval(timer);
                    console.log(`[PerfGraph] rating_history を検出しました。 (試行回数: ${attempts})`);
                    resolve(history);
                }

                if (attempts > 400) {
                    clearInterval(timer);
                    console.warn("[PerfGraph] rating_history の待機がタイムアウトしました。");
                    resolve(history || []);
                }
            }, 10);
        });
    }

    originalAddEventListener.call(window, 'DOMContentLoaded', async () => {
        // 定数群(公式コードベース)
        const MARGIN_VAL_X = 86400 * 30;
        const MARGIN_VAL_Y_LOW = 100;
        const MARGIN_VAL_Y_HIGH = 300;
        const OFFSET_X = 50;
        const OFFSET_Y = 5;

        let canvas_status = document.getElementById("ratingStatus");
        let canvas_graph = document.getElementById("ratingGraph");
        if (!canvas_status || !canvas_graph) {
            console.error("[PerfGraph] キャンバス要素が見つかりません。");
            return;
        }

        const BASE_WIDTH_GRAPH = parseInt(canvas_graph.getAttribute('width'), 10) || 640;
        const BASE_HEIGHT_GRAPH = parseInt(canvas_graph.getAttribute('height'), 10) || 360;
        const BASE_WIDTH_STATUS = parseInt(canvas_status.getAttribute('width'), 10) || 640;
        const BASE_HEIGHT_STATUS = parseInt(canvas_status.getAttribute('height'), 10) || 80;

        const PANEL_WIDTH = BASE_WIDTH_GRAPH - OFFSET_X - 10;
        const PANEL_HEIGHT = BASE_HEIGHT_GRAPH - OFFSET_Y - 30;
        const STATUS_WIDTH = BASE_WIDTH_STATUS - OFFSET_X - 10;
        const STATUS_HEIGHT = BASE_HEIGHT_STATUS - OFFSET_Y - 5;

        const HIGHEST_WIDTH = 115;
        const HIGHEST_HEIGHT = 20;

        const LABEL_FONT = "12px Lato";
        const START_YEAR = 2010;
        const MONTH_NAMES = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
        const YEAR_SEC = 86400*365;
        const STEP_SIZE = 400;

        const COLORS = [
            [0,    "#808080", 0.15],
            [400,  "#804000", 0.15],
            [800,  "#008000", 0.15],
            [1200, "#00C0C0", 0.2],
            [1600, "#0000FF", 0.1],
            [2000, "#C0C000", 0.25],
            [2400, "#FF8000", 0.2],
            [2800, "#FF0000", 0.1]
        ];

        const STAR_MIN = 3200;
        const PARTICLE_MIN = 3;
        const PARTICLE_MAX = 20;
        const LIFE_MAX = 30;

        var cj = createjs;
        var stage_graph, stage_status;

        var panel_shape, border_shape;
        var chart_container, line_shape, vertex_shapes, highest_shape;
        var n, x_min, x_max, y_min, y_max;

        var perf_line_shape, perf_vertex_shapes, perf_highest_shape;
        var perf_n, perf_y_min, perf_y_max;
        var perf_rating_history = [];
        var raw_json_data = [];
        var html_perf_map = new Map();

        var border_status_shape;
        var rating_text, place_text, diff_text, date_text, contest_name_text, perf_text;
        var particles;
        var standings_url;
        const username = document.getElementsByClassName("username")[0]?.textContent.trim() || "";

        let showPerformance = GM_getValue("showPerformance", true);
        let isExpanded = false;

        /**
         * AtCoderの現在の表示言語を取得する
         * @returns {string} "JA" (日本語) または "EN" (英語)
         */
        function getCurrentLanguage() {
            const elems = document.querySelectorAll("#navbar-collapse .dropdown > a");
            if (elems.length === 0) return "JA";

            for (let i = 0; i < elems.length; i++) {
                const text = elems[i].textContent;
                if (text?.includes("English")) return "EN";
                if (text?.includes("日本語")) return "JA";
            }
            console.warn("[PerfGraph] Language detection failed. Fallback to English.");
            return "EN";
        }

        // 多言語表示用テキスト
        const currentLang = getCurrentLanguage();
        const LOCALIZED_TEXT = {
            perf: "perf: ",
            btnOn: currentLang === "EN" ? "Performance: ON" : "パフォーマンス: ON",
            btnOff: currentLang === "EN" ? "Performance: OFF" : "パフォーマンス: OFF",
            btnDownload: currentLang === "EN" ? "Download Image" : "画像をダウンロード"
        };

        function initStage(stage, canvas, baseWidth, baseHeight) {
            var dpr = window.devicePixelRatio || 1;
            canvas.setAttribute('width', Math.round(baseWidth * dpr));
            canvas.setAttribute('height', Math.round(baseHeight * dpr));
            stage.scaleX = stage.scaleY = dpr;

            if (isExpanded) {
                canvas.style.maxWidth = canvas.style.maxHeight = "";
            } else {
                canvas.style.maxWidth = baseWidth + "px";
                canvas.style.maxHeight = baseHeight + "px";
            }
            canvas.style.width = canvas.style.height = "100%";
            stage.enableMouseOver();
        }

        function newShape(parent) { var s = new cj.Shape(); parent.addChild(s); return s; }
        function newText(parent, x, y, font) { var t = new cj.Text("", font, "#000"); t.x = x; t.y = y; t.textAlign = "center"; t.textBaseline = "middle"; parent.addChild(t); return t; }
        function getPer(x, l, r) { return (x - l) / (r - l); }
        function getColor(x) {
            for (var i = COLORS.length - 1; i >= 0; i--) { if (x >= COLORS[i][0]) return COLORS[i]; }
            return [-1, "#000000", 0.1];
        }
        function getRatingPer(x) { var pre = COLORS[COLORS.length-1][0] + STEP_SIZE; for (var i = COLORS.length - 1; i >= 0; i--) { if (x >= COLORS[i][0]) return (x - COLORS[i][0]) / (pre - COLORS[i][0]); pre = COLORS[i][0]; } return 0; }
        function getOrdinal(x) { var s = ["th", "st", "nd", "rd"], v = x % 100; return x + (s[(v - 20) % 10] || s[v] || s[0]); }
        function getDiff(x) { var sign = x==0?'±':(x<0?'-':'+'); return sign + Math.abs(x); }

        function setStatus(data, data2, particle_flag) {
            if (!data) return;
            var date = new Date(data.EndTime * 1000); var rating = data.NewRating, old_rating = data.OldRating;
            var place = data.Place; var contest_name = data.ContestName; var perf = data2 ? data2.Performance : 0;
            var tmp = getColor(rating); var color = tmp[1], alpha = tmp[2];
            var perf_tmp = getColor(perf); var perf_color = perf_tmp[1];

            if (border_status_shape && border_status_shape.graphics) {
                border_status_shape.graphics.c().s(color).ss(1).rr(OFFSET_X, OFFSET_Y, STATUS_WIDTH, STATUS_HEIGHT, 2);
            }
            if (rating_text) { rating_text.text = rating; rating_text.color = color; }
            if (place_text) place_text.text = getOrdinal(place);
            if (diff_text) diff_text.text = getDiff(rating-old_rating);
            if (date_text) date_text.text = date.toLocaleDateString();
            if (contest_name_text) contest_name_text.text = contest_name;

            if (particle_flag && rating_text) {
                var particle_num = parseInt(Math.pow(getRatingPer(rating), 2) * (PARTICLE_MAX - PARTICLE_MIN) + PARTICLE_MIN);
                setParticles(particle_num, color, alpha, rating);
            }
            standings_url = data.StandingsUrl;

            if (perf_text) {
                perf_text.text = (showPerformance && data2 && perf > 0) ? LOCALIZED_TEXT.perf + perf : "";
                perf_text.color = perf_color;
            }
        }

        // 演出用パーティクル処理
        function setParticle(particle, x, y, color, alpha, star_flag) { particle.x = x; particle.y = y; var ang = Math.random() * Math.PI * 2; var speed = Math.random() * 4 + 4; particle.vx = Math.cos(ang) * speed; particle.vy = Math.sin(ang) * speed; particle.rot_speed = Math.random()*20+10; particle.life = LIFE_MAX; particle.visible = true; particle.color = color; particle.text = star_flag ? "★" : "@"; particle.alpha = alpha; }
        function setParticles(num, color, alpha, rating) { for (var i = 0; i < PARTICLE_MAX; i++) { if (i < num && rating_text) { setParticle(particles[i], rating_text.x, rating_text.y, color, alpha, rating >= STAR_MIN); } else { particles[i].life = 0; particles[i].visible = false; } } }
        function updateParticle(particle) { if (particle.life <= 0) { particle.visible = false; return; } particle.x += particle.vx; particle.vx *= 0.9; particle.y += particle.vy; particle.vy *= 0.9; particle.life--; particle.scaleX = particle.scaleY = particle.life / LIFE_MAX; particle.rotation += particle.rot_speed; }
        function updateParticles() { for (var i = 0; i < PARTICLE_MAX; i++) { if (particles[i].life > 0) { updateParticle(particles[i]); } } }

        function remapPerformanceData(currentHistory) {
            if (!currentHistory || !Array.isArray(currentHistory) || raw_json_data.length === 0) return;

            let activeContests = new Set();
            for (let i = 0; i < currentHistory.length; i++) {
                if (currentHistory[i] && currentHistory[i].ContestName) {
                    activeContests.add(currentHistory[i].ContestName.trim());
                }
            }

            let validPerfMap = new Map();
            for (let i = 0; i < raw_json_data.length; i++) {
                if (raw_json_data[i].IsRated !== true) continue;

                // 英語かつ英語名データが存在するときだけ使用し、なければ通常の日本語名を使う
                let jsonContestName = (currentLang === "EN" && raw_json_data[i].ContestNameEn)
                ? raw_json_data[i].ContestNameEn.trim()
                : raw_json_data[i].ContestName?.trim();

                if (jsonContestName && activeContests.has(jsonContestName)) {
                    let perfVal = html_perf_map.has(jsonContestName) ? html_perf_map.get(jsonContestName) : 0;
                    validPerfMap.set(jsonContestName, { ...raw_json_data[i], Performance: perfVal });
                }
            }

            perf_rating_history = [];
            for (let i = 0; i < currentHistory.length; i++) {
                let cName = currentHistory[i].ContestName?.trim();
                if (cName && validPerfMap.has(cName)) {
                    perf_rating_history.push(validPerfMap.get(cName));
                } else {
                    perf_rating_history.push({ Performance: 0 });
                }
            }
        }

        function initBackground() {
            panel_shape = newShape(stage_graph); panel_shape.x = OFFSET_X; panel_shape.y = OFFSET_Y; panel_shape.alpha = 0.3;
            border_shape = newShape(stage_graph); border_shape.x = OFFSET_X; border_shape.y = OFFSET_Y;

            panel_shape.graphics.clear();
            border_shape.graphics.clear();

            function newLabelY(s, y) { var t = new cj.Text(s, LABEL_FONT, "#000"); t.x = OFFSET_X - 10; t.y = OFFSET_Y + y; t.textAlign = "right"; t.textBaseline = "middle"; stage_graph.addChild(t); }
            function newLabelX(s, x, y) { var t = new cj.Text(s, LABEL_FONT, "#000"); t.x = OFFSET_X + x; t.y = OFFSET_Y + PANEL_HEIGHT + 2 + y; t.textAlign = "center"; t.textBaseline = "top"; stage_graph.addChild(t); }

            var y1 = 0;
            for (var i = COLORS.length - 1; i >= 0; i--) {
                var y2 = PANEL_HEIGHT - PANEL_HEIGHT * getPer(COLORS[i][0], y_min, y_max);
                if (y2 > 0 && y1 < PANEL_HEIGHT) {
                    y1 = Math.max(y1, 0);
                    panel_shape.graphics.f(COLORS[i][1]).r(0, y1, PANEL_WIDTH, Math.min(y2, PANEL_HEIGHT) - y1);
                }
                y1 = y2;
            }
            for (var i = 0; i <= y_max; i += STEP_SIZE) {
                if (i >= y_min) {
                    var y = PANEL_HEIGHT - PANEL_HEIGHT * getPer(i, y_min, y_max);
                    newLabelY(String(i), y);
                    border_shape.graphics.s("#FFF").ss(0.5);
                    if (i == 2000) border_shape.graphics.s("#000");
                    border_shape.graphics.mt(0, y).lt(PANEL_WIDTH, y);
                }
            }

            var month_step = 6;
            for (var i = 3; i >= 1; i--) { if (x_max-x_min <= YEAR_SEC*i + MARGIN_VAL_X*2) month_step = i; }
            var first_flag = true;
            for (var i = START_YEAR; i < 3000; i++) {
                var break_flag = false;
                for (var j = 0; j < 12; j += month_step) {
                    var month = ('00' + (j+1)).slice(-2); var unix = Date.parse(String(i)+"-"+month+"-01T00:00:00")/1000;
                    if (x_min < unix && unix < x_max) {
                        var x = PANEL_WIDTH * getPer(unix, x_min, x_max);
                        if (j == 0 || first_flag) { newLabelX(MONTH_NAMES[j], x, 0); newLabelX(String(i), x, 13); first_flag = false; } else { newLabelX(MONTH_NAMES[j], x, 0); }
                        border_shape.graphics.s("#FFF").ss(0.5);
                        border_shape.graphics.mt(x, 0).lt(x, PANEL_HEIGHT);
                    }
                    if (unix > x_max) { break_flag = true; break;}
                }
                if (break_flag) break;
            }
            border_shape.graphics.s("#888").ss(1.5).rr(0, 0, PANEL_WIDTH, PANEL_HEIGHT, 2);
        }

        function initUnifiedChart(currentHistory) {
            var highest_tooltip_container = new cj.Container();
            stage_graph.addChild(highest_tooltip_container);

            chart_container = new cj.Container();
            stage_graph.addChild(chart_container);
            chart_container.shadow = new cj.Shadow("rgba(0,0,0,0.3)", 1, 2, 3);

            line_shape = newShape(chart_container);
            perf_line_shape = newShape(chart_container);
            vertex_shapes = new Array();
            perf_vertex_shapes = new Array();

            function mouseoverVertex(e) {
                if(e.currentTarget.is_vertex) { e.currentTarget.scaleX = e.currentTarget.scaleY = 1.2; }
                stage_graph.update();
                setStatus(currentHistory[e.currentTarget.i], perf_rating_history[e.currentTarget.i], true);
            }
            function mouseoutVertex(e) {
                if(e.currentTarget.is_vertex) { e.currentTarget.scaleX = e.currentTarget.scaleY = 1; }
                stage_graph.update();
            }
            function mouseoverPerfVertex(e) {
                if(e.currentTarget.is_vertex) { e.currentTarget.scaleX = e.currentTarget.scaleY = 1.2; }
                stage_graph.update();
                setStatus(currentHistory[e.currentTarget.i], perf_rating_history[e.currentTarget.i], true);
            }
            function mouseoutPerfVertex(e) {
                if(e.currentTarget.is_vertex) { e.currentTarget.scaleX = e.currentTarget.scaleY = 1; }
                stage_graph.update();
            }

            if (n > 0) {
                line_shape.graphics.s("#AAA").ss(2);
                line_shape.graphics.mt(OFFSET_X + PANEL_WIDTH * getPer(currentHistory[0].EndTime, x_min, x_max), OFFSET_Y + (PANEL_HEIGHT - PANEL_HEIGHT * getPer(currentHistory[0].NewRating, y_min, y_max)));
                for (var i = 0; i < n; i++) {
                    line_shape.graphics.lt(OFFSET_X + PANEL_WIDTH * getPer(currentHistory[i].EndTime, x_min, x_max), OFFSET_Y + (PANEL_HEIGHT - PANEL_HEIGHT * getPer(currentHistory[i].NewRating, y_min, y_max)));
                }
            }

            if (showPerformance && perf_n > 0) {
                perf_line_shape.graphics.s("#AAA").ss(2);
                let first_drawn = false;
                for (var i = 0; i < perf_n; i++) {
                    if (!perf_rating_history[i] || perf_rating_history[i].Performance === 0) continue;
                    let tx = OFFSET_X + PANEL_WIDTH * getPer(currentHistory[i].EndTime, x_min, x_max);
                    let ty = OFFSET_Y + (PANEL_HEIGHT - PANEL_HEIGHT * getPer(perf_rating_history[i].Performance, y_min, y_max));
                    if (!first_drawn) {
                        perf_line_shape.graphics.mt(tx, ty);
                        first_drawn = true;
                    } else {
                        perf_line_shape.graphics.lt(tx, ty);
                    }
                }
            }

            if (n > 0) {
                line_shape.graphics.s("#FFF").ss(0.5);
                line_shape.graphics.mt(OFFSET_X + PANEL_WIDTH * getPer(currentHistory[0].EndTime, x_min, x_max), OFFSET_Y + (PANEL_HEIGHT - PANEL_HEIGHT * getPer(currentHistory[0].NewRating, y_min, y_max)));
                for (var i = 0; i < n; i++) {
                    line_shape.graphics.lt(OFFSET_X + PANEL_WIDTH * getPer(currentHistory[i].EndTime, x_min, x_max), OFFSET_Y + (PANEL_HEIGHT - PANEL_HEIGHT * getPer(currentHistory[i].NewRating, y_min, y_max)));
                }
            }

            if (showPerformance && perf_n > 0) {
                perf_line_shape.graphics.s("#FFF").ss(0.5);
                let first_drawn = false;
                for (var i = 0; i < perf_n; i++) {
                    if (!perf_rating_history[i] || perf_rating_history[i].Performance === 0) continue;
                    let tx = OFFSET_X + PANEL_WIDTH * getPer(currentHistory[i].EndTime, x_min, x_max);
                    let ty = OFFSET_Y + (PANEL_HEIGHT - PANEL_HEIGHT * getPer(perf_rating_history[i].Performance, y_min, y_max));
                    if (!first_drawn) {
                        perf_line_shape.graphics.mt(tx, ty);
                        first_drawn = true;
                    } else {
                        perf_line_shape.graphics.lt(tx, ty);
                    }
                }
            }

            var highest_i = 0;
            for (var i = 0; i < n; i++) { if (currentHistory[highest_i].NewRating < currentHistory[i].NewRating) highest_i = i; }

            var highest_i_perf = -1;
            if (showPerformance && perf_n > 0) {
                for (var i = 0; i < perf_n; i++) {
                    if (!perf_rating_history[i] || perf_rating_history[i].Performance === 0) continue;
                    if (highest_i_perf === -1 || perf_rating_history[highest_i_perf].Performance < perf_rating_history[i].Performance) {
                        highest_i_perf = i;
                    }
                }
            }

            if (n > 0) {
                let hx = OFFSET_X + PANEL_WIDTH * getPer(currentHistory[highest_i].EndTime, x_min, x_max);
                let hy = OFFSET_Y + (PANEL_HEIGHT - PANEL_HEIGHT * getPer(currentHistory[highest_i].NewRating, y_min, y_max));
                highest_shape = newShape(highest_tooltip_container);
                highest_shape.shadow = new cj.Shadow("rgba(0,0,0,0.3)", 1, 2, 3);

                var dx = ((x_min + x_max)/2 < currentHistory[highest_i].EndTime) ? -80 : 80; var x = hx + dx; var y = hy - 16;
                highest_shape.graphics.s("#FFF").mt(hx, hy).lt(x, y);
                highest_shape.graphics.s("#888").f("#FFF").rr(x - HIGHEST_WIDTH/2, y - HIGHEST_HEIGHT/2, HIGHEST_WIDTH, HIGHEST_HEIGHT, 2); highest_shape.i = highest_i;
                highest_shape.is_vertex = false;

                var highest_text = newText(highest_tooltip_container, x, y, "12px Lato");
                highest_text.text = "Highest(Rate): " + currentHistory[highest_i].NewRating;
                highest_shape.addEventListener("mouseover", mouseoverVertex); highest_shape.addEventListener("mouseout", mouseoutVertex);
            }

            // Highest(Perf) 吹き出し描画の安全ガード
            if (showPerformance && highest_i_perf !== -1 &&
                currentHistory[highest_i_perf] &&
                perf_rating_history[highest_i_perf] &&
                perf_rating_history[highest_i_perf].Performance > 0) {

                let highestHistory = currentHistory[highest_i_perf];
                let hx_perf = OFFSET_X + PANEL_WIDTH * getPer(highestHistory.EndTime, x_min, x_max);
                let hy_perf = OFFSET_Y + (PANEL_HEIGHT - PANEL_HEIGHT * getPer(perf_rating_history[highest_i_perf].Performance, y_min, y_max));

                if (!isNaN(hx_perf) && !isNaN(hy_perf)) {
                    perf_highest_shape = newShape(highest_tooltip_container);
                    if (perf_highest_shape && perf_highest_shape.graphics) {
                        perf_highest_shape.shadow = new cj.Shadow("rgba(0,0,0,0.3)", 1, 2, 3);

                        var dx_perf = ((x_min + x_max) / 2 < highestHistory.EndTime) ? -80 : 80; var x_perf = hx_perf + dx_perf; var y_perf = hy_perf - 16;
                        try {
                            perf_highest_shape.graphics.s("#FFF").mt(hx_perf, hy_perf).lt(x_perf, y_perf);
                            perf_highest_shape.graphics.s("#888").f("#FFF").rr(x_perf - HIGHEST_WIDTH / 2, y_perf - HIGHEST_HEIGHT / 2, HIGHEST_WIDTH, HIGHEST_HEIGHT, 2); perf_highest_shape.i = highest_i_perf;
                            perf_highest_shape.is_vertex = false;

                            var highest_perf_text = newText(highest_tooltip_container, x_perf, y_perf, "12px Lato");
                            highest_perf_text.text = "Highest(Perf): " + perf_rating_history[highest_i_perf].Performance;
                            perf_highest_shape.addEventListener("mouseover", mouseoverPerfVertex); perf_highest_shape.addEventListener("mouseout", mouseoutPerfVertex);
                        } catch (graphicsError) {
                            console.warn("[PerfGraph] perf_highest_shape graphics error:", graphicsError);
                        }
                    }
                }
            }

            for (var i = 0; i < n; i++) {
                vertex_shapes.push(newShape(chart_container)); vertex_shapes[i].graphics.s("#FFF");
                if (i == highest_i) vertex_shapes[i].graphics.s("#000");
                vertex_shapes[i].graphics.ss(0.5).f(getColor(currentHistory[i].NewRating)[1]).dc(0, 0, 3.5);
                vertex_shapes[i].x = OFFSET_X + PANEL_WIDTH * getPer(currentHistory[i].EndTime, x_min, x_max);
                vertex_shapes[i].y = OFFSET_Y + (PANEL_HEIGHT - PANEL_HEIGHT * getPer(currentHistory[i].NewRating, y_min, y_max)); vertex_shapes[i].i = i;
                vertex_shapes[i].is_vertex = true;

                var hitArea = new cj.Shape(); hitArea.graphics.f("#000").dc(1.5, 1.5, 6); vertex_shapes[i].hitArea = hitArea;
                vertex_shapes[i].addEventListener("mouseover", mouseoverVertex); vertex_shapes[i].addEventListener("mouseout", mouseoutVertex);
            }

            // パフォーマンス頂点描画の安全ガード
            if (showPerformance && perf_n > 0) {
                for (var i = 0; i < perf_n; i++) {
                    if (!perf_rating_history[i] || perf_rating_history[i].Performance === 0) continue;

                    let vShape = newShape(chart_container);
                    if (!vShape || !vShape.graphics) continue;

                    perf_vertex_shapes.push(vShape);
                    vShape.graphics.s("#FFF");
                    if (i == highest_i_perf) vShape.graphics.s("#000");
                    let size = (i == highest_i_perf) ? 3.5 : 3.0;
                    vShape.graphics.ss(0.5).f(getColor(perf_rating_history[i].Performance)[1]).dc(0, 0, size);

                    let targetHistory = currentHistory[i];
                    if (targetHistory) {
                        vShape.x = OFFSET_X + PANEL_WIDTH * getPer(targetHistory.EndTime, x_min, x_max);
                        vShape.y = OFFSET_Y + (PANEL_HEIGHT - PANEL_HEIGHT * getPer(perf_rating_history[i].Performance, y_min, y_max));
                        vShape.i = i;
                        vShape.is_vertex = true;

                        var hitAreaPerf = new cj.Shape();
                        if (hitAreaPerf && hitAreaPerf.graphics) {
                            hitAreaPerf.graphics.f("#000").dc(1.5, 1.5, 6);
                            vShape.hitArea = hitAreaPerf;
                        }

                        vShape.addEventListener("mouseover", mouseoverPerfVertex); vShape.addEventListener("mouseout", mouseoutPerfVertex);
                    }
                }
            }
        }

        function initStatus(currentHistory) {
            border_status_shape = newShape(stage_status);
            rating_text = newText(stage_status, OFFSET_X + 75, OFFSET_Y + STATUS_HEIGHT / 2, "48px 'Squada One'");
            perf_text = newText(stage_status, OFFSET_X + 75, OFFSET_Y + STATUS_HEIGHT / 2 + 25, "16px 'Squada One'");
            place_text = newText(stage_status, OFFSET_X + 160, OFFSET_Y + STATUS_HEIGHT / 2.7, "16px Lato");
            diff_text = newText(stage_status, OFFSET_X + 160, OFFSET_Y + STATUS_HEIGHT / 1.5, "11px Lato"); diff_text.color = '#888';
            date_text = newText(stage_status, OFFSET_X + 200, OFFSET_Y + STATUS_HEIGHT / 4, "14px Lato"); contest_name_text = newText(stage_status, OFFSET_X + 200, OFFSET_Y + STATUS_HEIGHT / 1.6, "20px Lato");
            date_text.textAlign = contest_name_text.textAlign = "left"; contest_name_text.maxWidth = STATUS_WIDTH - 200 - 10;
            { var hitArea = new cj.Shape(); hitArea.graphics.f("#000").r(0,-12,contest_name_text.maxWidth,24); contest_name_text.hitArea = hitArea; contest_name_text.cursor = "pointer"; contest_name_text.addEventListener("click", function(){ location.href = standings_url; }); }

            particles = new Array();
            for (var i = 0; i < PARTICLE_MAX; i++) { particles.push(newText(stage_status, 0, 0, "64px Lato")); particles[i].visible = false; }
            if (currentHistory && currentHistory.length > 0) {
                setStatus(currentHistory[currentHistory.length-1], perf_rating_history[perf_rating_history.length-1], false);
            }
        }

        function myCustomInit() {
            let currentHistory = window.rating_history || (typeof unsafeWindow !== 'undefined' ? unsafeWindow.rating_history : undefined);
            n = (currentHistory && Array.isArray(currentHistory)) ? currentHistory.length : 0;

            if (n == 0) {
                console.warn("[PerfGraph] 表示データ数が0件のため描画を保留します。");
                return;
            }

            const oldGraph = document.getElementById("ratingGraph");
            const oldStatus = document.getElementById("ratingStatus");
            if (oldGraph && oldStatus) {
                // 【対策】古い Canvas に紐づく CreateJS のステージが存在する場合、
                // マウスオーバー監視を完全に無効化し、すべてのイベントを無効にする
                if (window.stage_graph) {
                    try {
                        window.stage_graph.enableMouseOver(0);
                        window.stage_graph.removeAllChildren();
                        window.stage_graph.clear();
                    } catch(e) {}
                }
                if (window.stage_status) {
                    try {
                        window.stage_status.enableMouseOver(0);
                        window.stage_status.removeAllChildren();
                        window.stage_status.clear();
                    } catch(e) {}
                }

                // Canvas要素をクローンすることで、古いCanvasに公式スクリプトが直接登録した
                // DOMレベルのイベントリスナー(clickやmouseover等)を物理的にすべて破棄します
                const newGraph = oldGraph.cloneNode(true);
                const newStatus = oldStatus.cloneNode(true);
                oldGraph.parentNode.replaceChild(newGraph, oldGraph);
                oldStatus.parentNode.replaceChild(newStatus, oldStatus);
                canvas_graph = newGraph;
                canvas_status = newStatus;
            }

            // 【超強固対策1】描画前に Canvas 自体のグラフィックスコンテキストを真っ白にクリアする
            const graphCtx = canvas_graph.getContext("2d");
            if (graphCtx) graphCtx.clearRect(0, 0, canvas_graph.width, canvas_graph.height);
            const statusCtx = canvas_status.getContext("2d");
            if (statusCtx) statusCtx.clearRect(0, 0, canvas_status.width, canvas_status.height);

            remapPerformanceData(currentHistory);
            perf_n = perf_rating_history.length;

            stage_graph = new cj.Stage("ratingGraph");
            stage_status = new cj.Stage("ratingStatus");

            // 【超強固対策2】以前のステージの登録内容が存在すればクリアし、メモリ上からも一掃
            if (window.stage_graph) { window.stage_graph.clear(); window.stage_graph.removeAllChildren(); }
            if (window.stage_status) { window.stage_status.clear(); window.stage_status.removeAllChildren(); }

            window.stage_graph = stage_graph;
            window.stage_status = stage_status;

            // 【超強固対策2】以前のステージの登録内容が存在すればクリアし、メモリ上からも一掃
            if (window.stage_graph) {
                // 【追加】マウスオーバーイベントの監視を明示的に無効化
                window.stage_graph.enableMouseOver(0);

                // 【追加】古い頂点シェイプのイベントリスナーとhitAreaを明示的に解放してゴースト当たり判定を消滅させる
                if (vertex_shapes && vertex_shapes.length > 0) {
                    vertex_shapes.forEach(v => {
                        if (v) {
                            v.removeAllEventListeners();
                            if (v.hitArea) v.hitArea = null;
                        }
                    });
                }
                if (perf_vertex_shapes && perf_vertex_shapes.length > 0) {
                    perf_vertex_shapes.forEach(v => {
                        if (v) {
                            v.removeAllEventListeners();
                            if (v.hitArea) v.hitArea = null;
                        }
                    });
                }

                window.stage_graph.clear();
                window.stage_graph.removeAllChildren();
            }
            if (window.stage_status) {
                window.stage_status.enableMouseOver(0); // 【追加】
                window.stage_status.clear();
                window.stage_status.removeAllChildren();
            }

            // 新しいステージの作成と代入
            stage_graph = new cj.Stage("ratingGraph");
            stage_status = new cj.Stage("ratingStatus");

            window.stage_graph = stage_graph;
            window.stage_status = stage_status;

            initStage(stage_graph, canvas_graph, BASE_WIDTH_GRAPH, BASE_HEIGHT_GRAPH);
            initStage(stage_status, canvas_status, BASE_WIDTH_STATUS, BASE_HEIGHT_STATUS);

            x_min = 100000000000; x_max = 0; y_min = 10000; y_max = 0;
            for (var i = 0; i < n; i++) {
                if (!currentHistory[i] || isNaN(currentHistory[i].EndTime)) continue;
                x_min = Math.min(x_min, currentHistory[i].EndTime);
                x_max = Math.max(x_max, currentHistory[i].EndTime);
                y_min = Math.min(y_min, currentHistory[i].NewRating);
                y_max = Math.max(y_max, currentHistory[i].NewRating);
            }
            x_min -= MARGIN_VAL_X; x_max += MARGIN_VAL_X;
            y_min = Math.min(1500, Math.max(0, y_min - MARGIN_VAL_Y_LOW));
            y_max += MARGIN_VAL_Y_HIGH;

            perf_y_min = 10000; perf_y_max = 0;
            for (var i = 0; i < perf_n; i++) {
                if (!perf_rating_history[i] || isNaN(perf_rating_history[i].Performance)) continue;
                perf_y_min = Math.min(perf_y_min, perf_rating_history[i].Performance);
                perf_y_max = Math.max(perf_y_max, perf_rating_history[i].Performance);
            }
            perf_y_min = Math.min(1500, Math.max(0, perf_y_min - MARGIN_VAL_Y_LOW));
            perf_y_max += MARGIN_VAL_Y_HIGH;

            if (showPerformance && perf_n > 0) {
                y_min = Math.min(y_min, perf_y_min);
                y_max = Math.max(y_max, perf_y_max);
            }

            initBackground();
            initUnifiedChart(currentHistory);
            stage_graph.update();
            initStatus(currentHistory);
            stage_status.update();

            cj.Ticker.setFPS(60);
            cj.Ticker.removeAllEventListeners("tick");
            cj.Ticker.addEventListener("tick", handleTick);

            function handleTick(event) { updateParticles(); stage_status.update(); }
            console.log("[PerfGraph] グラフの描画に成功しました。");
        }

        window.__customInit = myCustomInit;

        $('#rating-graph-expand').click(function() {
            isExpanded = true;
            canvas_status.style.maxWidth = canvas_status.style.maxHeight = "";
            canvas_graph.style.maxWidth = canvas_graph.style.maxHeight = "";
            $(this).css('cssText', 'display: none !important;');
        });

        function injectOnoffButton() {
            function updateButtonState(button) {
                if (showPerformance) {
                    button.textContent = LOCALIZED_TEXT.btnOn;
                    button.className = 'btn btn-primary btn-sm';
                } else {
                    button.textContent = LOCALIZED_TEXT.btnOff;
                    button.className = 'btn btn-default btn-sm';
                }
            }

            let insertButton = document.getElementById('onoffButton');
            let downloadButton = document.getElementById('downloadGraphButton');

            const btnGroup = document.querySelector('.btn-group.btn-group-sm');
            if (btnGroup && btnGroup.parentNode) {
                const targetParent = btnGroup.parentNode;

                if (!insertButton) {
                    const newButton = document.createElement('button');
                    newButton.id = 'onoffButton';
                    newButton.style.marginLeft = '12px';
                    newButton.style.width = '140px';       // ボタンの幅を140pxに固定
                    newButton.style.textAlign = 'center'; // 【追加】文字を中央揃えにする
                    newButton.style.paddingLeft = '0';    // 【追加】左パディングをリセット
                    newButton.style.paddingRight = '0';   // 【追加】右パディングをリセット

                    newButton.addEventListener('click', function () {
                        showPerformance = !showPerformance;
                        GM_setValue("showPerformance", showPerformance);
                        updateButtonState(newButton);
                        newButton.blur();
                        myCustomInit();
                    });

                    updateButtonState(newButton);
                    targetParent.appendChild(newButton);
                } else {
                    updateButtonState(insertButton);
                }

                if (!downloadButton) {
                    const newDownloadBtn = document.createElement('button');
                    newDownloadBtn.id = 'downloadGraphButton';
                    newDownloadBtn.className = 'btn btn-default btn-sm';
                    newDownloadBtn.style.marginLeft = '6px';
                    newDownloadBtn.textContent = LOCALIZED_TEXT.btnDownload;

                    newDownloadBtn.addEventListener('click', function() {
                        const canvasStatus = document.getElementById("ratingStatus");
                        const canvasGraph = document.getElementById("ratingGraph");

                        if (canvasStatus && canvasGraph) {
                            const combinedCanvas = document.createElement('canvas');
                            const ctx = combinedCanvas.getContext('2d');

                            const w = Math.max(canvasStatus.width, canvasGraph.width);
                            const h1 = canvasStatus.height;
                            const h2 = canvasGraph.height;

                            combinedCanvas.width = w;
                            combinedCanvas.height = h1 + h2;

                            ctx.fillStyle = "#FFFFFF";
                            ctx.fillRect(0, 0, combinedCanvas.width, combinedCanvas.height);

                            ctx.drawImage(canvasStatus, 0, 0);
                            ctx.drawImage(canvasGraph, 0, h1);

                            const imageURI = combinedCanvas.toDataURL("image/png");

                            const triggerLink = document.createElement('a');
                            triggerLink.download = `${username}_atcoder_full_graph.png`;
                            triggerLink.href = imageURI;
                            document.body.appendChild(triggerLink);
                            triggerLink.click();
                            document.body.removeChild(triggerLink);
                        }
                        newDownloadBtn.blur();
                    });

                    targetParent.appendChild(newDownloadBtn);
                }
            }
        }

        /**
         * 429エラー対策:フェッチ処理を行い、429を検知した場合は指数バックオフで再試行する
         */
        async function fetchWithRetry(url, maxRetries = 4, baseDelay = 300) {
            for (let i = 0; i <= maxRetries; i++) {
                try {
                    const response = await fetch(url);
                    if (response.status === 429) {
                        if (i === maxRetries) {
                            throw new Error(`429: 最大再試行回数に達しました (${url})`);
                        }
                        // 指数バックオフ: 1秒 -> 2秒 -> 4秒 -> 8秒 と待ち時間を倍増させる
                        const delay = baseDelay * Math.pow(2, i);
                        console.warn(`[PerfGraph] 429 Too Many Requestsを検知しました。${delay}ms後に再試行します。 (試行: ${i + 1}/${maxRetries})`);
                        await new Promise(resolve => setTimeout(resolve, delay));
                        continue;
                    }
                    if (!response.ok) {
                        throw new Error(`HTTP Error: ${response.status}`);
                    }
                    return response;
                } catch (error) {
                    if (i === maxRetries) throw error;
                    // ネットワークエラー等の場合も1秒待機して再試行
                    console.warn(`[PerfGraph] 通信エラーが発生しました。1秒後に再試行します。`, error);
                    await new Promise(resolve => setTimeout(resolve, baseDelay));
                }
            }
        }

        async function main() {
            if (!username) return;

            injectOnoffButton();
            let currentHistory = await waitOfRatingHistory();

            try {
                const urlParams = new URLSearchParams(window.location.search);
                let fetchParam = "";
                if (urlParams.get('contestType') === 'heuristic') {
                    fetchParam = "?contestType=heuristic";
                }

                console.log("[PerfGraph] バックグラウンド情報のフェッチを開始します...");
                let parser = new DOMParser();

                // 通常の fetch を fetchWithRetry に置き換え
                const jsonResponse = await fetchWithRetry(`https://atcoder.jp/users/${username}/history/json${fetchParam}`);
                let json = await jsonResponse.json();

                const textResponse = await fetchWithRetry(`https://atcoder.jp/users/${username}/history${fetchParam}`);
                let text = await textResponse.text();

                let historyEl = parser.parseFromString(text, "text/html").getElementById("history");
                if (!historyEl) {
                    myCustomInit();
                    return;
                }

                let rows = historyEl.querySelectorAll("tbody tr");
                html_perf_map.clear();

                rows.forEach(row => {
                    let tds = row.querySelectorAll("td");
                    if (tds.length >= 4) {
                        let contestLinkEl = tds[1]?.querySelector('a');
                        let perfCell = tds[3];

                        if (contestLinkEl && perfCell) {
                            let contestName = contestLinkEl.innerText.trim();
                            let perfText = perfCell.innerText.trim();
                            if (perfText !== "ー" && perfText !== "-" && perfText !== "") {
                                let perfNum = Number(perfText);
                                if (!isNaN(perfNum)) html_perf_map.set(contestName, perfNum);
                            }
                        }
                    }
                });

                raw_json_data = json;
                console.log(`[PerfGraph] パフォーマンスデータのバックグラウンド取得が完了しました。 (JSON: ${json.length}件)`);

            } catch (e) {
                console.error('[PerfGraph] フェッチエラー', e);
            }

            myCustomInit();

            let lastLength = (window.rating_history || (typeof unsafeWindow !== 'undefined' ? unsafeWindow.rating_history : []) || []).length;
            setInterval(() => {
                let checkHistory = window.rating_history || (typeof unsafeWindow !== 'undefined' ? unsafeWindow.rating_history : []) || [];
                if (checkHistory.length !== lastLength) {
                    console.log(`[PerfGraph] データ数変更を検知しました (${lastLength} -> ${checkHistory.length})。再描画します。`);
                    lastLength = checkHistory.length;
                    myCustomInit();
                }
            }, 300);
        }

        main();
    });
})();