IMDb Rating Colors

Applies rating color codes to IMDb episode heatmaps, histograms, creates a Season Average section, and adds "Save Image" functionality. Works for all languages.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         IMDb Rating Colors
// @namespace    http://tampermonkey.net/
// @version      2.6
// @description  Applies rating color codes to IMDb episode heatmaps, histograms, creates a Season Average section, and adds "Save Image" functionality. Works for all languages.
// @author       Windy
// @match        https://www.imdb.com/title/*/ratings*
// @match        https://www.imdb.com/*/title/*/ratings*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=imdb.com
// @require      https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // --- CONFIGURATION ---
    const BLEND_CAP = 0.8;
    const DEBOUNCE_TIME = 200; // ms
    const FETCH_DELAY = 1250;   // Time in ms to wait before fetching data
    const SHOW_TOOLTIPS = false; // Set to false to disable tooltips
    const TRANSPOSE_THRESHOLD = 25; // If episodes > this, swap X/Y axis in screenshot

    const COLOR_STOPS = [
        { val: 9.6, rgb: [29, 161, 242],  label: 'Masterpiece',   text: '#ffffff', showInLegend: true },
        { val: 9.0, rgb: [24, 106, 59],   label: 'Amazing',       text: '#ffffff', showInLegend: true },
        { val: 8.0, rgb: [40, 180, 99],   label: 'Great',         text: '#2a2a2a', showInLegend: true },
        { val: 7.0, rgb: [244, 208, 63],  label: 'Good',          text: '#2a2a2a', showInLegend: true },
        { val: 6.0, rgb: [243, 156, 18],  label: 'Average',       text: '#2a2a2a', showInLegend: true },
        { val: 5.0, rgb: [231, 76, 60],   label: 'Bad',           text: '#ffffff', showInLegend: true },
        { val: 4.0, rgb: [99, 57, 116],   label: 'Trash',         text: '#ffffff', showInLegend: false }, // Math Anchor
        { val: 0.0, rgb: [99, 57, 116],   label: 'Trash',         text: '#ffffff', showInLegend: true }
    ];

    const STORAGE_KEY_GRADIENT = 'sg_gradient_mode_enabled';

    let isGradientMode = localStorage.getItem(STORAGE_KEY_GRADIENT) === 'true';

    // Global Cache
    const TITLE_CACHE = new Map();
    const FETCH_PROMISES = new Map();

    // --- UTILS ---
    function debounce(func, wait) {
        let timeout;
        return function(...args) {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), wait);
        };
    }

    function rgbToString(rgb) {
        return `rgb(${Math.round(rgb[0])}, ${Math.round(rgb[1])}, ${Math.round(rgb[2])})`;
    }

    function blendColors(color1, color2, percentage) {
        const p = percentage;
        const w = 1 - p;
        const r = Math.sqrt((w * (color1[0] ** 2)) + (p * (color2[0] ** 2)));
        const g = Math.sqrt((w * (color1[1] ** 2)) + (p * (color2[1] ** 2)));
        const b = Math.sqrt((w * (color1[2] ** 2)) + (p * (color2[2] ** 2)));
        return [r, g, b];
    }

    function getStyleForRating(rating) {
        if (isGradientMode) {
            if (rating >= COLOR_STOPS[0].val) return { bg: rgbToString(COLOR_STOPS[0].rgb), text: COLOR_STOPS[0].text };
            if (rating <= COLOR_STOPS[COLOR_STOPS.length - 1].val) return { bg: rgbToString(COLOR_STOPS[COLOR_STOPS.length - 1].rgb), text: COLOR_STOPS[COLOR_STOPS.length - 1].text };
            for (let i = 0; i < COLOR_STOPS.length - 1; i++) {
                const upper = COLOR_STOPS[i];
                const lower = COLOR_STOPS[i+1];
                if (rating < upper.val && rating >= lower.val) {
                    const range = upper.val - lower.val;
                    const rawProgress = (range === 0) ? 0 : (rating - lower.val) / range;
                    const weightedProgress = rawProgress * BLEND_CAP;
                    const finalRgb = blendColors(lower.rgb, upper.rgb, weightedProgress);
                    return { bg: rgbToString(finalRgb), text: lower.text };
                }
            }
        } else {
            for (const stop of COLOR_STOPS) {
                if (!stop.showInLegend && stop.val === 4.0) continue;
                if (rating >= stop.val) return { bg: rgbToString(stop.rgb), text: stop.text };
            }
            return { bg: rgbToString(COLOR_STOPS[COLOR_STOPS.length-1].rgb), text: '#ffffff' };
        }
    }

    function formatVoteCount(count) {
        if (!count) return '';
        if (count >= 1000000) return (count / 1000000).toFixed(1) + 'M';
        if (count >= 1000) return (count / 1000).toFixed(1) + 'K';
        return count;
    }

    // --- TITLE SCRAPER & FETCHER ---
    function scrapeVisibleTitles() {
        const items = document.querySelectorAll('.ipc-metadata-list-summary-item');
        items.forEach(item => {
            const link = item.querySelector('a.ipc-title-link-wrapper') || item.querySelector('a[href^="/title/"]');
            const titleEl = item.querySelector('.ipc-title__text');

            if (link && titleEl) {
                const rawHref = link.getAttribute('href');
                if(!rawHref) return;
                const href = rawHref.split('?')[0].replace(/\/$/, '');
                const text = titleEl.innerText.replace(/^\d+\.\s+/, '');
                TITLE_CACHE.set(href, text);
            }
        });
    }

    async function fetchEpisodeTitle(url) {
        const cleanUrl = url.split('?')[0].replace(/\/$/, '');
        if (TITLE_CACHE.has(cleanUrl)) return TITLE_CACHE.get(cleanUrl);
        if (FETCH_PROMISES.has(cleanUrl)) return FETCH_PROMISES.get(cleanUrl);

        const promise = (async () => {
            try {
                const resp = await fetch(cleanUrl);
                const text = await resp.text();
                // JSON-LD
                const jsonMatch = text.match(/<script type="application\/ld\+json">(.*?)<\/script>/s);
                if (jsonMatch && jsonMatch[1]) {
                    try {
                        const data = JSON.parse(jsonMatch[1]);
                        if (data.name) {
                            TITLE_CACHE.set(cleanUrl, data.name);
                            return data.name;
                        }
                    } catch(e) {}
                }
                // OG Meta
                const ogMatch = text.match(/<meta property="og:title" content="(.*?)"/);
                if (ogMatch) {
                    const cleanTitle = ogMatch[1].replace(/\(TV Episode.*$/, '').replace(/ - .*$/, '').trim();
                    TITLE_CACHE.set(cleanUrl, cleanTitle);
                    return cleanTitle;
                }
            } catch (e) { console.error("SG Colors: Failed to fetch title", e); }
            return null;
        })();

        FETCH_PROMISES.set(cleanUrl, promise);
        return promise;
    }

    // --- MAIN SERIES DATA FETCH (For Screenshot) ---
    async function fetchSeriesData() {
        const match = window.location.pathname.match(/\/title\/(tt\d+)/);
        if (!match) return null;
        const ttId = match[1];
        const url = `https://www.imdb.com/title/${ttId}/`;

        try {
            const resp = await fetch(url);
            const text = await resp.text();
            let data = { poster: null, rating: null, votes: null, title: null };

            // 1. Try JSON-LD
            const jsonMatch = text.match(/<script type="application\/ld\+json">(.*?)<\/script>/s);
            if (jsonMatch && jsonMatch[1]) {
                try {
                    const json = JSON.parse(jsonMatch[1]);
                    data.title = json.name;
                    data.poster = json.image;
                    if (json.aggregateRating) {
                        data.rating = json.aggregateRating.ratingValue;
                        data.votes = formatVoteCount(json.aggregateRating.ratingCount);
                    }
                } catch(e) {}
            }

            // 2. Fallbacks
            if (!data.poster) {
                const ogImg = text.match(/<meta property="og:image" content="(.*?)"/);
                if (ogImg) data.poster = ogImg[1];
            }
            if (data.poster && data.poster.includes('._V1_')) {
                data.poster = data.poster.replace(/(\._V1_)(.*)(\.jpg|\.png|\.jpeg)$/, '$1$3');
            }

            return data;
        } catch (e) {
            console.error("SG Colors: Fetch series data failed", e);
            return null;
        }
    }

    // --- CUSTOM TOOLTIP ---
    let tooltipEl = null;
    let hoverTimeout = null;

    function initTooltip() {
        if (document.getElementById('sg-custom-tooltip')) return;
        tooltipEl = document.createElement('div');
        tooltipEl.id = 'sg-custom-tooltip';
        document.body.appendChild(tooltipEl);
    }

    function showTooltip(e, season, epNum, rating, href) {
        if (!SHOW_TOOLTIPS) return;
        if (!tooltipEl) initTooltip();
        if (hoverTimeout) clearTimeout(hoverTimeout);

        const cleanHref = href.split('?')[0].replace(/\/$/, '');
        let title = TITLE_CACHE.get(cleanHref);

        let html = `<div class="sg-tt-header">${season} Ep ${epNum} <span class="sg-tt-score">${rating}</span></div>`;

        if (title) {
            html += `<div class="sg-tt-title">${title}</div>`;
        } else {
            html += `<div class="sg-tt-loading">...</div>`;
            hoverTimeout = setTimeout(() => {
                fetchEpisodeTitle(href).then(fetchedTitle => {
                    if (fetchedTitle && tooltipEl.style.display === 'block' && tooltipEl.dataset.activeHref === cleanHref) {
                        const loadingEl = tooltipEl.querySelector('.sg-tt-loading');
                        if (loadingEl) loadingEl.outerHTML = `<div class="sg-tt-title">${fetchedTitle}</div>`;
                    }
                });
            }, FETCH_DELAY);
        }

        tooltipEl.innerHTML = html;
        tooltipEl.dataset.activeHref = cleanHref;
        tooltipEl.style.display = 'block';
        moveTooltip(e);
    }

    function moveTooltip(e) {
        if (!tooltipEl) return;
        const x = e.clientX + 15;
        const y = e.clientY + 15;
        const rect = tooltipEl.getBoundingClientRect();
        const winW = window.innerWidth;
        const winH = window.innerHeight;
        const finalX = (x + rect.width > winW) ? e.clientX - rect.width - 10 : x;
        const finalY = (y + rect.height > winH) ? e.clientY - rect.height - 10 : y;
        tooltipEl.style.left = finalX + 'px';
        tooltipEl.style.top = finalY + 'px';
    }

    function hideTooltip() {
        if (hoverTimeout) clearTimeout(hoverTimeout);
        if (tooltipEl) tooltipEl.style.display = 'none';
    }

    // --- SCREENSHOT HELPERS ---
    async function ensureAllSeasonsLoaded() {
        const MAX_CLICKS = 20;
        let clickCount = 0;
        const getBtn = () => document.querySelector('[data-testid="heatmap__load-seasons"]');
        let btn = getBtn();
        while (btn && clickCount < MAX_CLICKS) {
            btn.click();
            clickCount++;
            await new Promise(r => setTimeout(r, 600));
            btn = getBtn();
        }
        if (clickCount > 0) await new Promise(r => setTimeout(r, 500));
    }

    async function ensureAllEpisodesLoaded() {
        const MAX_CLICKS = 30;
        let clickCount = 0;
        const getArrow = () => document.querySelector('.ipc-pager--right.ipc-pager--visible');
        let arrow = getArrow();
        while (arrow && clickCount < MAX_CLICKS) {
            arrow.click();
            clickCount++;
            await new Promise(r => setTimeout(r, 250));
            arrow = getArrow();
        }
        if (clickCount > 0) await new Promise(r => setTimeout(r, 300));
    }

    // Extract table data into a JS object to handle transposition easier
    function getGridData() {
        const sCol = document.querySelector('[data-testid="heatmap__seasons-column"]');
        const eData = document.querySelector('[data-testid="heatmap__episode-data"]');
        if (!sCol || !eData) return null;

        const sRows = Array.from(sCol.querySelectorAll('tr'));
        const eRows = Array.from(eData.querySelectorAll('tr'));
        const rowCount = Math.min(sRows.length, eRows.length);

        let seasons = [];
        let maxEps = 0;

        for (let i = 0; i < rowCount; i++) {
            // FIX: Skip rows that are actually headers (containing <th> instead of ratings)
            if (eRows[i].querySelector('th')) continue;

            const epCells = eRows[i].querySelectorAll('td');
            // If row has no data cells, skip it (spacer/header)
            if (epCells.length === 0) continue;

            let name = sRows[i].innerText.trim();
            if (!name) name = `S${seasons.length + 1}`;

            let episodes = [];
            epCells.forEach((cell, idx) => {
                const a = cell.querySelector('a');
                if (a) {
                    episodes.push({
                        val: parseFloat(a.innerText.replace(',', '.')),
                        text: a.innerText
                    });
                } else {
                    episodes.push(null);
                }
            });

            if (episodes.length > maxEps) maxEps = episodes.length;
            seasons.push({ name, episodes });
        }

        return { seasons, maxEps };
    }

async function downloadGraph() {
        const btn = document.getElementById('sg-download-btn');
        const updateBtn = (text) => { if(btn) btn.innerText = text; };

        try {
            updateBtn('Loading Seasons...');
            await ensureAllSeasonsLoaded();

            updateBtn('Loading Episodes...');
            await ensureAllEpisodesLoaded();

            updateBtn('Fetching Data...');

            const seriesData = await fetchSeriesData();
            const gridData = getGridData();
            if (!gridData) throw new Error("Could not parse grid data");

            // --- TITLE FIX ---
            const domTitle = document.querySelector('[data-testid="hero__primary-text"]') ||
                             document.querySelector('h1[data-testid="hero__pageTitle"]') ||
                             document.querySelector('h2[data-testid="subtitle"]');
            let titleText = domTitle ? domTitle.innerText : (seriesData?.title || 'IMDb Chart');

            let posterSrc = seriesData?.poster;
            if (!posterSrc) {
                const lockup = document.querySelector('.ipc-lockup-overlay');
                const fallback = document.querySelector('.ipc-poster__poster-image img') || document.querySelector('[data-testid="hero-media__poster"] img');
                if (lockup && lockup.previousElementSibling?.tagName === 'IMG') {
                    posterSrc = lockup.previousElementSibling.src;
                } else if (fallback) {
                    posterSrc = fallback.src;
                }
            }

            // --- DETERMINE LAYOUT (Transpose Check) ---
            const shouldTranspose = gridData.maxEps > TRANSPOSE_THRESHOLD;

            // Calculate Widths
            const CELL_W = shouldTranspose ? 60 : 55; // Wider cells if Seasons on top
            const ROW_H = '45px';

            let dataTableWidth;
            if (shouldTranspose) {
                dataTableWidth = (gridData.seasons.length * CELL_W) + 60; // Seasons on X axis
            } else {
                dataTableWidth = (gridData.maxEps * CELL_W) + 120; // Episodes on X axis
            }

            // 320px (Sidebar) + 60px (Padding) + Buffer
            const totalWidth = dataTableWidth + 380;

            const wrapper = document.createElement('div');
            wrapper.style.cssText = `position: absolute; top: 0; left: 0; z-index: -9999; background-color: #1F1F1F; color: #ffffff; width: ${totalWidth}px; font-family: Roboto, Helvetica, Arial, sans-serif; display: flex; flex-direction: row; border-radius: 4px; overflow: hidden;`;

            // Sidebar
            const sidebar = document.createElement('div');
            sidebar.style.cssText = `width: 320px; padding: 30px; display: flex; flex-direction: column; gap: 20px; background-color: #1a1a1a; border-right: 1px solid #333; flex-shrink: 0;`;

            if (posterSrc) {
                const imgContainer = document.createElement('div');
                imgContainer.style.cssText = `width: 100%; display: flex; justify-content: center; margin-bottom: 5px;`;
                const img = new Image();
                img.src = posterSrc;
                img.crossOrigin = "anonymous";
                img.style.cssText = `width: 100%; height: auto; border-radius: 8px; box-shadow: 0 8px 20px rgba(0,0,0,0.6);`;
                imgContainer.appendChild(img);
                sidebar.appendChild(imgContainer);
            }

            const hText = document.createElement('h1');
            hText.innerText = titleText;
            hText.style.cssText = `font-size: 26px; line-height: 1.2; margin: 0; color: #fff; font-weight: 800; text-align: center;`;
            sidebar.appendChild(hText);

            if (seriesData && seriesData.rating) {
                const rateBlock = document.createElement('div');
                rateBlock.style.cssText = `display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding-bottom: 10px; border-bottom: 1px solid rgba(255,255,255,0.1);`;
                const scoreLine = document.createElement('div');
                scoreLine.style.cssText = `display: flex; align-items: center; gap: 8px;`;
                scoreLine.innerHTML = `
                    <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="#f5c518"><path d="M12 17.27l4.15 2.51c.76.46 1.69-.22 1.49-1.08l-1.1-4.72 3.67-3.18c.67-.58.31-1.68-.57-1.75l-4.83-.41-1.89-4.46c-.34-.81-1.5-.81-1.84 0L9.19 8.63l-4.83.41c-.88.07-1.24 1.17-.57 1.75l3.67 3.18-1.1 4.72c-.2.86.73 1.54 1.49 1.08l4.15-2.5z"></path></svg>
                    <span style="font-size: 28px; font-weight: bold; color: #fff;">${seriesData.rating}</span>
                `;
                const votesLine = document.createElement('div');
                votesLine.innerText = `${seriesData.votes} ratings`;
                votesLine.style.cssText = `font-size: 13px; color: #888; text-transform: uppercase; letter-spacing: 1px; font-weight: 500;`;
                rateBlock.appendChild(scoreLine);
                rateBlock.appendChild(votesLine);
                sidebar.appendChild(rateBlock);
            }

            // Legend
            const legend = document.getElementById('sg-legend-container');
            if (legend) {
                const legendClone = legend.cloneNode(true);
                legendClone.querySelectorAll('.sg-toggle-wrapper, #sg-download-btn, .sg-control-group').forEach(c => c.remove());
                legendClone.style.cssText = `margin-top: 30px; padding: 15px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; display: flex; flex-direction: column; align-items: center; justify-content: center; white-space: normal;`;
                const itemsDiv = legendClone.querySelector('.sg-legend-items');
                if(itemsDiv) {
                    itemsDiv.style.display = 'flex';
                    itemsDiv.style.flexWrap = 'wrap';
                    itemsDiv.style.justifyContent = 'center';
                    itemsDiv.style.gap = '8px';
                }
                sidebar.appendChild(legendClone);
            }
            wrapper.appendChild(sidebar);

            const mainContent = document.createElement('div');
            mainContent.style.cssText = `flex-grow: 1; padding: 30px; background-color: #1F1F1F; display: flex; flex-direction: column; gap: 20px; overflow: visible; width: max-content;`;

            // Averages
            const avgPanel = document.getElementById('sg-season-averages-container');
            if (avgPanel) {
                const avgClone = avgPanel.cloneNode(true);
                avgClone.style.cssText = 'background: transparent; border: none; padding: 0; box-shadow: none;';
                const avgTitle = avgClone.querySelector('h3'); if(avgTitle) avgTitle.style.color = '#ffffff';
                const infoIcon = avgClone.querySelector('.sg-info-icon'); if (infoIcon) infoIcon.style.display = 'none';

                avgClone.querySelectorAll('.sg-season-card').forEach(card => {
                    card.style.borderColor = 'rgba(255,255,255,0.1)';
                    card.style.background = 'rgba(255,255,255,0.03)';
                    card.querySelector('.sg-season-label').style.color = '#ccc';

                    const score = card.querySelector('.sg-season-score');
                    score.style.boxShadow = 'none';
                    score.style.border = 'none';

                    const val = parseFloat(score.innerText);
                    if (!isNaN(val)) {
                        const style = getStyleForRating(val);
                        score.style.color = style.text;
                    }
                });
                mainContent.appendChild(avgClone);
            }

            // --- BUILD HEATMAP (TRANSPOSED VS NORMAL) ---
            const heatmapContainer = document.createElement('div');

            if (shouldTranspose) {
                // *** TRANSPOSED BUILD (Rows = Episodes, Cols = Seasons) ***
                const table = document.createElement('table');
                table.style.cssText = 'border-collapse: collapse; width: auto; font-size: 13px;';

                // Header (Seasons)
                const thead = document.createElement('thead');
                const hRow = document.createElement('tr');
                // Corner Cell
                const corner = document.createElement('th');
                corner.innerText = "Ep";
                corner.style.cssText = `padding: 8px; color: #bbbbbb; font-weight: 600; text-align: center; border-bottom: 1px solid #333; width: 40px;`;
                hRow.appendChild(corner);

                gridData.seasons.forEach(s => {
                    const th = document.createElement('th');
                    th.innerText = s.name.replace(/^Season\s/,'S'); // Shorten "Season 1" to "S1" if needed
                    th.style.cssText = `padding: 8px 4px; color: #e0e0e0; font-weight: bold; text-align: center; width: ${CELL_W}px;`;
                    hRow.appendChild(th);
                });
                thead.appendChild(hRow);
                table.appendChild(thead);

                // Body (Episodes)
                const tbody = document.createElement('tbody');
                for (let eIdx = 0; eIdx < gridData.maxEps; eIdx++) {
                    const row = document.createElement('tr');

                    // Ep Number Cell
                    const numCell = document.createElement('td');
                    numCell.innerText = eIdx + 1;
                    numCell.style.cssText = `padding: 4px; color: #bbbbbb; text-align: center; font-size: 11px; height: ${ROW_H}; vertical-align: middle;`;
                    row.appendChild(numCell);

                    // Data Cells
                    gridData.seasons.forEach(season => {
                        const td = document.createElement('td');
                        td.style.padding = '2px 4px';
                        td.style.verticalAlign = 'middle';

                        const epData = season.episodes[eIdx];
                        if (epData && !isNaN(epData.val)) {
                            const style = getStyleForRating(epData.val);
                            const div = document.createElement('div');
                            div.innerText = epData.val.toFixed(1);
                            div.style.cssText = `
                                width: 100%; height: 36px; display: flex; align-items: center; justify-content: center;
                                border-radius: 4px; background-color: ${style.bg}; color: ${style.text}; font-weight: 600;
                            `;
                            td.appendChild(div);
                        }
                        row.appendChild(td);
                    });
                    tbody.appendChild(row);
                }
                table.appendChild(tbody);
                heatmapContainer.appendChild(table);

            } else {
                // *** STANDARD BUILD (Rows = Seasons, Cols = Episodes) ***
                const heatmapRow = document.createElement('div');
                heatmapRow.style.cssText = `display: flex; flex-direction: row; align-items: flex-start;`;

                const seasonTable = document.querySelector('[data-testid="heatmap__seasons-column"]');
                const episodeTable = document.querySelector('[data-testid="heatmap__episode-data"]');

                if (seasonTable && episodeTable) {
                    const sClone = seasonTable.cloneNode(true);
                    const eClone = episodeTable.cloneNode(true);

                    sClone.style.cssText = 'border-collapse: collapse; margin-right: 8px;';
                    eClone.style.cssText = 'border-collapse: collapse; width: auto; table-layout: auto;';

                    // Align Rows
                    const sRows = sClone.querySelectorAll('tr');
                    const eRows = eClone.querySelectorAll('tr');
                    const rowCount = Math.max(sRows.length, eRows.length);

                    for (let i = 0; i < rowCount; i++) {
                        if (sRows[i]) {
                            const cells = sRows[i].querySelectorAll('td, th');
                            cells.forEach(c => {
                                c.style.height = ROW_H;
                                c.style.padding = '0';
                                c.style.textAlign = 'center';
                                c.style.width = '60px';
                                c.style.verticalAlign = 'middle';
                                c.style.color = '#e0e0e0';
                                const innerDiv = c.querySelector('div');
                                if (innerDiv) {
                                    innerDiv.style.color = '#e0e0e0';
                                    innerDiv.style.display = 'flex';
                                    innerDiv.style.alignItems = 'center';
                                    innerDiv.style.justifyContent = 'center';
                                    innerDiv.style.height = '100%';
                                    innerDiv.style.width = '100%';
                                    innerDiv.style.transform = 'translateX(-8px)';
                                }
                            });
                        }
                        if (eRows[i]) {
                            const cells = eRows[i].querySelectorAll('td, th');
                            cells.forEach(c => {
                                c.style.height = ROW_H;
                                c.style.padding = '0 4px';
                                c.style.verticalAlign = 'middle';
                            });
                        }
                    }

                    // Style Episode Cells
                    eClone.querySelectorAll('*').forEach(el => {
                        if (el.classList.contains('sg-rating-text')) {

                            const val = parseFloat(el.innerText.replace(',', '.'));
                            if (!isNaN(val)) {
                                const style = getStyleForRating(val);
                                el.style.color = style.text;
                            } else {
                                el.style.color = '#2a2a2a'; // fallback
                            }
                        } else if (el.tagName === 'TH') {
                            el.style.color = '#bbbbbb';
                            el.style.fontSize = '12px';
                        } else if (el.classList.contains('sg-rating-cell')) {
                            el.style.width = '48px';
                            el.style.height = '36px';
                            el.style.borderRadius = '4px';
                            el.style.display = 'flex';
                            el.style.alignItems = 'center';
                            el.style.justifyContent = 'center';
                        }
                    });

                    heatmapRow.appendChild(sClone);
                    heatmapRow.appendChild(eClone);
                    heatmapContainer.appendChild(heatmapRow);
                }
            }

            mainContent.appendChild(heatmapContainer);
            wrapper.appendChild(mainContent);
            document.body.appendChild(wrapper);

            updateBtn('Rendering...');
            await new Promise(r => setTimeout(r, 200));

            const canvas = await html2canvas(wrapper, {
                useCORS: true, backgroundColor: '#1F1F1F', scale: 2,
                width: totalWidth, windowWidth: totalWidth
            });

            const link = document.createElement('a');
            link.download = `${titleText.replace(/[^a-z0-9]/gi, '_')}.png`;
            link.href = canvas.toDataURL('image/png');
            link.click();

            document.body.removeChild(wrapper);

        } catch (e) {
            console.error("Screenshot failed:", e);
            alert("Could not generate image. Check console.");
        } finally {
            updateBtn('Save Image');
        }
    }

// --- CSS ---
    const STYLES = `
        /* Tooltip Styles */
        #sg-custom-tooltip {
            display: none; position: fixed; z-index: 10000; pointer-events: none;
            background: rgba(0, 0, 0, 0.9); color: #fff; padding: 10px 14px;
            border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.4);
            font-family: Roboto, Helvetica, Arial, sans-serif; font-size: 13px;
            border: 1px solid rgba(255,255,255,0.15); max-width: 300px;
        }
        .sg-tt-header { font-weight: 400; color: #bbb; display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
        .sg-tt-score { font-weight: bold; color: #f5c518; font-size: 13px; }
        .sg-tt-title { font-weight: 700; font-size: 14px; line-height: 1.3; }
        .sg-tt-loading { font-style: italic; color: #888; font-size: 12px; margin-top: 2px; }

        /* General Styles */
        #sg-legend-container {
            display: flex; flex-wrap: nowrap; gap: 20px; margin: 16px 0 24px 0; padding: 12px 20px;
            align-items: center; justify-content: center;
            background: var(--ipt-on-base-alt-bg-color, rgba(0,0,0,0.03));
            border-radius: 12px; border: 1px solid var(--ipt-on-base-border-color, rgba(0,0,0,0.1));
            font-family: Roboto, Helvetica, Arial, sans-serif;
            box-shadow: 0 4px 12px rgba(0,0,0,0.05);
            white-space: nowrap;
        }
        .sg-legend-items { display: flex; flex-wrap: nowrap; gap: 8px; align-items: center; }
        .sg-badge {
            display: inline-flex; align-items: center; justify-content: center; padding: 5px 12px;
            border-radius: 20px; font-size: 12px; font-weight: 700; letter-spacing: 0.3px;
            cursor: help; text-shadow: 0 1px 1px rgba(0,0,0,0.1); user-select: none;
            border: 1px solid rgba(255,255,255,0.2); transition: transform 0.2s;
        }
        .sg-badge:hover { transform: translateY(-2px); }
        #sg-season-averages-container { margin: 0; display: flex; flex-direction: column; }
        #sg-season-averages-container .ipc-title,
        #sg-season-averages-container .ipc-title hgroup,
        #sg-season-averages-container .ipc-title__text { overflow: visible !important; text-overflow: clip !important; white-space: normal !important; }
        #sg-season-averages-container .ipc-title { margin-bottom: 0px !important; padding-bottom: 0px !important; }
        .sg-season-avg-grid { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; padding: 0; margin-left: 10px; }
        .sg-season-card {
            display: flex; flex-direction: column; align-items: center; justify-content: center;
            background: var(--ipt-on-base-alt-bg-color, rgba(255,255,255,0.03));
            border-radius: 8px; padding: 12px; min-width: 70px;
            border: 1px solid var(--ipt-on-base-border-color, rgba(255,255,255,0.1));
            transition: transform 0.2s;
        }
        .sg-season-card:hover { transform: translateY(-2px); border-color: rgba(255,255,255,0.3); }
        .sg-season-label { font-size: 12px; font-weight: 600; text-transform: uppercase; opacity: 0.7; margin-bottom: 8px; }
        .sg-season-score {
            font-size: 18px; font-weight: bold; padding: 6px 14px; border-radius: 6px;
            text-align: center; width: 100%; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.15);
            text-shadow: 0 1px 2px rgba(0,0,0,0.3);
            transition: background-color 0.2s ease, color 0.2s ease;
        }
        .sg-info-icon {
            display: inline-flex; justify-content: center; align-items: center;
            width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid currentColor;
            font-size: 12px; font-weight: bold; cursor: help; position: relative; opacity: 0.6;
            margin-left: 8px; vertical-align: middle; color: var(--ipt-on-base-textPrimary-color);
            transform: translateY(-1px);
        }
        .sg-info-icon:hover { opacity: 1; }
        .sg-tooltip {
            visibility: hidden; background-color: #222; color: #fff; text-align: center;
            border-radius: 6px; padding: 8px 12px; position: absolute; z-index: 9999;
            bottom: 135%; left: 50%; transform: translateX(-50%);
            width: 220px; font-size: 12px; font-weight: normal;
            box-shadow: 0 4px 10px rgba(0,0,0,0.3); opacity: 0; transition: opacity 0.3s;
            pointer-events: none; border: 1px solid #444; line-height: 1.4;
        }
        .sg-info-icon:hover .sg-tooltip { visibility: visible; opacity: 1; }
        .sg-tooltip::after {
            content: ""; position: absolute; top: 100%; left: 50%; margin-left: -5px;
            border-width: 5px; border-style: solid; border-color: #222 transparent transparent transparent;
        }
        .sg-rating-cell {
            border-radius: 4px !important;
            background-image: linear-gradient(to bottom, rgba(255,255,255,0.15) 0%, rgba(0,0,0,0.05) 100%);
            transition: background-color 0.2s ease;
        }
        .sg-rating-text { font-weight: 600; text-shadow: 0 1px 2px rgba(0,0,0,0.25); }

        /* Controls */
        .sg-toggle-wrapper { display: flex; align-items: center; gap: 8px; margin-left: 10px; }
        .sg-toggle-label { font-size: 13px; font-weight: 600; cursor: pointer; user-select: none; color: var(--ipt-on-base-textPrimary-color, inherit); }
        .sg-switch { position: relative; display: inline-block; width: 36px; height: 20px; }
        .sg-switch input { opacity: 0; width: 0; height: 0; }
        .sg-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .3s; border-radius: 20px; }
        .sg-slider:before { position: absolute; content: ""; height: 14px; width: 14px; left: 3px; bottom: 3px; background-color: white; transition: .3s; border-radius: 50%; box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
        input:checked + .sg-slider { background-color: #f5c518; }
        input:checked + .sg-slider:before { transform: translateX(16px); }

        #sg-download-btn {
            background: none; border: 1px solid var(--ipt-on-base-border-color, rgba(255,255,255,0.2));
            color: var(--ipt-on-base-textPrimary-color, inherit);
            border-radius: 6px; padding: 4px 12px; cursor: pointer; font-size: 13px; font-weight: 600;
            transition: background 0.2s; display: flex; align-items: center; justify-content: center;
        }
        #sg-download-btn:hover { background: rgba(255,255,255,0.1); }
    `;

    // --- DOM FUNCTIONS ---

    function injectStyles() {
        if (!document.getElementById('sg-styles')) {
            const styleEl = document.createElement('style');
            styleEl.id = 'sg-styles';
            styleEl.innerHTML = STYLES;
            document.head.appendChild(styleEl);
        }
    }

    function injectLegend() {
        if (document.getElementById('sg-legend-container')) return;

        const target = document.querySelector('[data-testid="heatmap__root-element"]')
                    || document.querySelector('.ipc-chip-list')
                    || document.querySelector('[data-testid="histogram-container"]');
        if (!target) return;

        const container = document.createElement('div');
        container.id = 'sg-legend-container';

        const items = document.createElement('div');
        items.className = 'sg-legend-items';

        COLOR_STOPS.forEach(c => {
            if (!c.showInLegend) return;
            const b = document.createElement('div');
            b.className = 'sg-badge';
            b.style.backgroundColor = rgbToString(c.rgb);
            b.style.color = c.text;
            b.innerText = c.label;
            let max = (c.val === 9.6) ? 10.0 : (c.val + 0.9).toFixed(1);
            if (c.val === 9.0) max = 9.5; if (c.val === 5.0) max = 5.9; if (c.val === 0.0) max = 4.9;
            b.title = `${c.val} - ${max}`;
            items.appendChild(b);
        });

        // Gradient Toggle
        const gradWrap = document.createElement('div');
        gradWrap.className = 'sg-toggle-wrapper';
        gradWrap.innerHTML = `
            <label class="sg-toggle-label" for="sg-grad-sw">Gradient</label>
            <label class="sg-switch"><input type="checkbox" id="sg-grad-sw" ${isGradientMode?'checked':''}><span class="sg-slider"></span></label>
        `;

        // Save Image Button
        const dlBtn = document.createElement('button');
        dlBtn.id = 'sg-download-btn';
        dlBtn.innerText = 'Save Image';
        dlBtn.onclick = downloadGraph;

        // Append everything in one line (flex-wrap: wrap handles overflow if screen is small)
        container.append(items, gradWrap, dlBtn);

        gradWrap.querySelector('input').addEventListener('change', (e) => {
            isGradientMode = e.target.checked;
            localStorage.setItem(STORAGE_KEY_GRADIENT, isGradientMode);
            colorizeAndBind(true);
            injectAveragesPanel();
        });

        if (target.parentNode) target.parentNode.insertBefore(container, target);
    }

    function calculateAverages() {
        const results = [];
        const sCol = document.querySelector('[data-testid="heatmap__seasons-column"]');
        const eData = document.querySelector('[data-testid="heatmap__episode-data"]');
        if (!sCol || !eData) return results;

        const sRows = Array.from(sCol.querySelectorAll('td.ratings-heatmap__table-data'));
        const eRows = Array.from(eData.querySelectorAll('tbody tr'));
        const len = Math.min(sRows.length, eRows.length);

        for (let i = 0; i < len; i++) {
            const name = sRows[i].innerText.trim();
            const links = eRows[i].querySelectorAll('a');
            let sum = 0, count = 0;
            links.forEach(l => {
                const v = parseFloat(l.innerText.replace(',', '.'));
                if (!isNaN(v)) { sum += v; count++; }
            });
            if (count > 0) results.push({ name, avg: (sum/count).toFixed(1) });
        }
        return results;
    }

    function injectAveragesPanel() {
        const hmSec = document.querySelector('section[data-testid="sub-section-heatmap"]');
        const moreSec = document.querySelector('section[data-testid="more-from-section"]');
        if (!hmSec) return;

        const avgs = calculateAverages();
        if (!avgs.length) return;

        let panel = document.getElementById('sg-season-averages-container');
        if (!panel) {
            panel = document.createElement('div');
            panel.id = 'sg-season-averages-container';
            panel.className = 'ipc-page-section ipc-page-section--base';
            panel.innerHTML = `
                <div class="ipc-title ipc-title--base ipc-title--section-title ipc-title--on-textPrimary">
                    <hgroup>
                        <h3 class="ipc-title__text">
                            <span>Season Averages</span>
                            <div class="sg-info-icon">i
                                <span class="sg-tooltip">Average calculated based on currently loaded episodes. Load more episodes to update.</span>
                            </div>
                        </h3>
                    </hgroup>
                </div>
                <div id="sg-season-avg-grid" class="sg-season-avg-grid"></div>
            `;
            if (moreSec && moreSec.parentNode) moreSec.parentNode.insertBefore(panel, moreSec);
            else hmSec.parentNode.insertBefore(panel, hmSec.nextSibling);
        }

        const grid = panel.querySelector('#sg-season-avg-grid');
        avgs.forEach(season => {
            let card = grid.querySelector(`.sg-season-card[data-season="${season.name}"]`);
            const style = getStyleForRating(parseFloat(season.avg));

            if (card) {
                const scoreDiv = card.querySelector('.sg-season-score');
                scoreDiv.innerText = season.avg;
                scoreDiv.dataset.rating = season.avg;
                scoreDiv.style.backgroundColor = style.bg;
                scoreDiv.style.color = style.text;
            } else {
                card = document.createElement('div');
                card.className = 'sg-season-card';
                card.dataset.season = season.name;
                card.innerHTML = `
                    <div class="sg-season-label">${season.name}</div>
                    <div class="sg-season-score" data-rating="${season.avg}" style="background-color:${style.bg}; color:${style.text}">${season.avg}</div>
                `;
                grid.appendChild(card);
            }
        });
    }

    // --- MAIN LOGIC ---

    function attachHoverEvents(element, seasonName, epNum, rating, href) {
        if (element.hasAttribute('title')) element.removeAttribute('title');

        const newEl = element.cloneNode(true);
        if(element.parentNode) element.parentNode.replaceChild(newEl, element);

        newEl.addEventListener('mouseenter', (e) => showTooltip(e, seasonName, epNum, rating, href));
        newEl.addEventListener('mousemove', (e) => moveTooltip(e));
        newEl.addEventListener('mouseleave', () => hideTooltip());

        return newEl;
    }

    function colorizeAndBind(forceUpdate = false) {
        scrapeVisibleTitles();

        const sCol = document.querySelector('[data-testid="heatmap__seasons-column"]');
        const eData = document.querySelector('[data-testid="heatmap__episode-data"]');

        if (sCol && eData) {
            const sRows = Array.from(sCol.querySelectorAll('td.ratings-heatmap__table-data'));
            const eRows = Array.from(eData.querySelectorAll('tbody tr'));
            const len = Math.min(sRows.length, eRows.length);

            for (let i = 0; i < len; i++) {
                const seasonName = sRows[i].innerText.trim() || `S${i+1}`;
                const cells = eRows[i].querySelectorAll('td.ratings-heatmap__table-data > div');

                cells.forEach((cell, index) => {
                    if (cell.dataset.sgProcessed && !forceUpdate) return;

                    const a = cell.querySelector('a');
                    if (a) {
                        const val = parseFloat(a.innerText.replace(',', '.'));
                        if (!isNaN(val)) {
                            const s = getStyleForRating(val);
                            cell.classList.add('sg-rating-cell');
                            cell.style.backgroundColor = s.bg;
                            a.classList.add('sg-rating-text');
                            a.style.color = s.text;

                            if (!cell.dataset.sgProcessed) {
                                const href = a.getAttribute('href');
                                if (href) {
                                    attachHoverEvents(a, seasonName, index + 1, val, href);
                                    cell.dataset.sgProcessed = "true";
                                }
                            }
                        }
                    }
                });
            }
        }

        document.querySelectorAll('.sg-season-score').forEach(div => {
            const val = parseFloat(div.dataset.rating);
            if (!isNaN(val)) {
                const s = getStyleForRating(val);
                div.style.backgroundColor = s.bg;
                div.style.color = s.text;
            }
        });

        document.querySelectorAll('[data-testid="histogram-container"] path[aria-label]').forEach(p => {
            const label = p.getAttribute('aria-label');
            const match = label && label.match(/^(\d+)/);
            if (match) {
                const val = parseFloat(match[1]);
                const s = getStyleForRating(val);
                p.style.fill = s.bg;
                p.style.stroke = s.bg;
            }
        });
    }

    function runUpdates(force = false) {
        injectLegend();
        injectAveragesPanel();
        colorizeAndBind(force);
    }

    const lazyUpdate = debounce(() => runUpdates(), DEBOUNCE_TIME);

    function init() {
        injectStyles();
        initTooltip();
        runUpdates();

        const obs = new MutationObserver((mutations) => {
            let changed = false;
            for (const m of mutations) {
                if (m.type === 'childList' && m.addedNodes.length) {
                    changed = true;
                    break;
                }
            }
            if (changed) lazyUpdate();
        });

        const body = document.querySelector('body');
        if (body) obs.observe(body, { childList: true, subtree: true });
    }

    init();

})();