Torn Racing Telemetry

Enhances Torn Racing with real-time telemetry, race stats history, and recent race logs.

当前为 2025-02-25 提交的版本,查看 最新版本

// ==UserScript==
// @name         Torn Racing Telemetry
// @namespace    https://www.torn.com/profiles.php?XID=2782979
// @version      2.4.3
// @description  Enhances Torn Racing with real-time telemetry, race stats history, and recent race logs.
// @match        https://www.torn.com/page.php?sid=racing*
// @match        https://www.torn.com/loader.php?sid=racing*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_connect
// @connect      api.torn.com
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    if (window.racingTelemetryScriptHasRun) return;
    window.racingTelemetryScriptHasRun = true;

    const defaultConfig = {
        displayMode: 'speed',
        colorCode: true,
        animateChanges: true,
        speedUnit: 'mph',
        periodicCheckInterval: 1000, // Hardcoded to 1 second (1000ms)
        minUpdateInterval: 500,
        language: 'en',
        apiKey: ''
    };

    // Load config and log it with JSON parse fix
    let config = Object.assign({}, defaultConfig, (() => {
        const savedConfig = GM_getValue('racingTelemetryConfig', null);
        if (savedConfig) {
            try {
                return JSON.parse(savedConfig);
            } catch (e) {
                console.error("Error parsing saved config, using default:", e);
                return {}; // Parsing failed, return empty object to avoid errors
            }
        }
        return {}; // No saved config found, return empty object to merge with defaultConfig
    })());

    let currentRaceId = null;
    let raceStarted = false;
    let periodicCheckIntervalId = null;
    let telemetryVisible = true;
    let observers = [];
    let lastUpdateTimes = {};

    const state = {
        previousMetrics: {},
        trackInfo: { total: 0, laps: 0, length: 0 },
        racingStats: null,
        statsHistory: [],
        raceLog: []
    };

    (function initStorage() {
        let savedStatsHistory = GM_getValue('statsHistory', []);
        if (!Array.isArray(savedStatsHistory)) savedStatsHistory = [];
        state.statsHistory = savedStatsHistory.filter(entry => entry && entry.timestamp && entry.skill).slice(0, 50);

        const savedRaces = GM_getValue('raceLog', []);
        state.raceLog = (Array.isArray(savedRaces) ? savedRaces.filter(r => r?.id).slice(0, 50) : []);
    })();

    const utils = {
        convertSpeed(speed, unit) {
            return unit === 'kmh' ? speed * 1.60934 : speed;
        },
        formatTime(seconds) {
            const minutes = Math.floor(seconds / 60);
            const secs = Math.floor(seconds % 60);
            return `${minutes}:${secs < 10 ? '0' : ''}${secs}`;
        },
        parseTime(timeString) {
            if (!timeString || !timeString.includes(':')) return 0;
            const parts = timeString.split(':');
            let minutes = 0;
            let seconds = 0;

            if (parts.length === 2) { // MM:SS.SS or MM:SS.HH format
                minutes = parseInt(parts[0], 10) || 0;
                seconds = parseFloat(parts[1]) || 0; // Parse seconds with decimal part
            } else if (parts.length === 3) { // Optional HH:MM:SS.SS format in future?
                // If we ever encounter HH:MM:SS.SS, handle it here. For now assume MM:SS.SS is standard.
                console.warn("Time string with 3 parts encountered, assuming HH:MM:SS.SS and taking last two parts as MM:SS.SS:", timeString);
                minutes = parseInt(parts[1], 10) || 0;
                seconds = parseFloat(parts[2]) || 0;
            } else {
                console.error("Unexpected time string format:", timeString);
                return 0; // Or handle error as needed
            }

            return (minutes * 60) + seconds; // Total seconds
        },
        parseProgress(text) {
            const match = text.match(/(\d+\.?\d*)%/);
            return match ? parseFloat(match[1]) : 0;
        },
        displayError(message) {
            const errorDiv = document.createElement('div');
            errorDiv.style.cssText = 'position: fixed; bottom: 10px; right: 10px; background: #f44336; color: #fff; padding: 10px; border-radius: 5px;';
            errorDiv.textContent = message;
            document.body.appendChild(errorDiv);
            setTimeout(() => errorDiv.remove(), 5000);
        },
        fetchWithRetry: async (url, retries = 3) => {
            try {
                const response = await fetch(url);
                if (!response.ok) throw new Error(`HTTP ${response.status}`);
                return response.json();
            } catch (e) {
                if (retries > 0) return utils.fetchWithRetry(url, retries - 1);
                throw e;
            }
        },
        formatTimestamp: (timestamp) => {
            let date;
            if (typeof timestamp === 'number') {
                date = new Date(timestamp);
            } else if (typeof timestamp === 'string') {
                date = new Date(timestamp);
            } else {
                return 'N/A';
            }

            if (isNaN(date)) {
                console.error("Invalid date object created from timestamp:", timestamp);
                return 'Invalid Date';
            }

            return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
        }
    };

    const dataManager = {
        async fetchRacingStats() {
            if (!config.apiKey?.trim()) return;
            try {
                const data = await utils.fetchWithRetry(
                    `https://api.torn.com/v2/user/personalstats?cat=racing&key=${config.apiKey}`
                );
                if (data.error) throw new Error(data.error.error);
                this.processStats(data.personalstats.racing);
            } catch (e) {
                console.error('Stats fetch failed:', e);
                utils.displayError(`API Error: ${e.message}`);
            }
        },
        processStats(newStats) {
            const oldStats = state.racingStats?.racing;
            const changes = {
                timestamp: Date.now(),
                skill: { old: oldStats?.skill || 0, new: newStats.skill },
                points: { old: oldStats?.points || 0, new: newStats.points },
                racesEntered: { old: oldStats?.races?.entered || 0, new: newStats.races?.entered },
                racesWon: { old: oldStats?.races?.won || 0, new: newStats.races?.won }
            };

            if (!oldStats || JSON.stringify(changes) !== JSON.stringify(oldStats)) {
                state.statsHistory.unshift(changes);
                state.statsHistory = state.statsHistory.slice(0, 50);
                GM_setValue('statsHistory', state.statsHistory);
            }
            state.racingStats = { racing: newStats };
            GM_setValue('racingStats', JSON.stringify(state.racingStats));
            const statsPanelContent = document.querySelector('.stats-history-popup .popup-content');
            if (statsPanelContent) {
                statsPanelContent.innerHTML = generateStatsContent();
            }
        },
        async fetchRaces() {
            if (!config.apiKey?.trim()) return;
            try {
                const data = await utils.fetchWithRetry(
                    `https://api.torn.com/v2/user/races?key=${config.apiKey}&limit=10&sort=DESC&cat=official`
                );
                data.races?.forEach(race => this.processRace(race));
                GM_setValue('raceLog', state.raceLog);
                 const racesPanelContent = document.querySelector('.recent-races-popup .popup-content');
                 if (racesPanelContent) {
                     racesPanelContent.innerHTML = generateRacesContent();
                 }
            } catch (e) {
                console.error('Race fetch failed:', e);
                utils.displayError(`Race data error: ${e.message}`);
            }
        },
        processRace(race) {
            const exists = state.raceLog.some(r => r.id === race.id);
            if (!exists && race?.id) {
                state.raceLog.unshift({ ...race, fetchedAt: Date.now() });
                state.raceLog = state.raceLog.slice(0, 50);
            }
        }
    };


    GM_addStyle(`
        :root {
            --primary-color: #4CAF50;
            --background-dark: #1a1a1a;
            --background-light: #2a2a2a;
            --text-color: #e0e0e0;
            --border-color: #404040;
            --accent-color: #64B5F6; /* Example accent color */
        }
        body.telemetry-hidden #telemetryButtonContainer {
            margin-bottom: 0; /* Adjust margin when telemetry is hidden */
        }
        .telemetry-button-container {
            display: flex;
            width: 100%;
            margin: 0 0px 0px; /* Reduced side margin, added bottom margin */
            border-radius: 6px;
            overflow: hidden;
            background: var(--background-dark); /* Set background for the container */
            border: 1px solid var(--border-color);
        }

        .telemetry-button {
            flex-grow: 1;
            background: transparent; /* Button background is transparent */
            color: var(--text-color);
            border: none; /* No individual button borders */
            padding: 12px 15px; /* Slightly increased padding */
            text-align: center;
            cursor: pointer;
            transition: background-color 0.2s ease, color 0.2s ease; /* Smoother transitions */
            font-size: 15px; /* Slightly larger font size */
        }

        .telemetry-button:hover {
            background-color: var(--background-light); /* Lighter background on hover */
            color: var(--accent-color); /* Accent color on hover */
        }

        .telemetry-button:active {
            background-color: var(--accent-color); /* Accent color on active */
            color: var(--background-dark); /* Dark background text on active */
        }

        .telemetry-button:not(:first-child) {
            /* No specific styling needed as we removed individual borders */
        }

        .telemetry-popup-container {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background-color: rgba(0, 0, 0, 0.6); /* Slightly darker semi-transparent background */
            display: flex;
            justify-content: center;
            align-items: center;
            z-index: 1000;
            backdrop-filter: blur(5px); /* Added blur for a modern look */
        }

        .telemetry-popup {
            background: var(--background-dark);
            border-radius: 10px; /* More rounded corners for popup */
            padding: 20px; /* Increased padding for more space */
            color: var(--text-color);
            font-family: 'Arial', sans-serif;
            box-shadow: 0 8px 16px rgba(0,0,0,0.6); /* Increased shadow for depth */
            width: 90%; /* Slightly wider on default */
            max-width: 700px; /* Maximum width for larger screens */
            max-height: 95%;
            overflow-y: auto;
            position: relative;
            border: 1px solid var(--border-color); /* Added border to popup */
        }

        .popup-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 15px; /* Increased margin */
            padding-bottom: 8px;
            border-bottom: 1px solid var(--border-color); /* Separator line */
        }

        .popup-title {
            font-size: 1.4em; /* Slightly larger title */
            font-weight: bold;
            color: var(--primary-color);
        }

        .close-button {
            background: var(--background-light);
            border: none;
            border-radius: 6px;
            color: var(--text-color);
            padding: 8px 16px; /* Adjusted padding */
            cursor: pointer;
            transition: background-color 0.2s ease, color 0.2s ease;
            font-size: 14px;
        }

        .close-button:hover {
            background-color: var(--accent-color);
            color: var(--background-dark);
        }

        .popup-content {
            padding: 15px 0; /* Vertical padding for content */
            /* background: #202020; - Inherited from .telemetry-popup now */
            border-radius: 8px;
            max-height: 70vh; /* Increased max height for content scroll */
            overflow-y: auto;
        }


        #leaderBoard .driver-item .name {
            display: flex !important;
            align-items: center;
            min-width: 0;
            padding-right: 10px !important;
        }
        #leaderBoard .driver-item .name > span:first-child {
            flex: 1 1 auto;
            min-width: 0;
            overflow: hidden;
            text-overflow: ellipsis;
            white-space: nowrap;
            padding-right: 8px;
        }
        .driver-status {
            flex: 0 0 auto;
            margin-left: auto;
            font-size: 11px;
            background: rgba(0,0,0,0.3);
            padding: 2px 6px;
            border-radius: 3px;
            white-space: nowrap;
            transition: color ${config.minUpdateInterval / 1000}s ease, opacity 0.3s ease;
        }
        .telemetry-hidden .driver-status { display: none; }
        @media (max-width: 480px) {
            .telemetry-button-container {
                flex-direction: column; /* Stack buttons on mobile */
                margin: 0 5px 10px; /* Adjust mobile margins */
                border-radius: 8px;
            }
            .telemetry-button {
                border-radius: 0; /* No border radius for stacked buttons */
            }
            .telemetry-button:not(:last-child) {
                border-bottom: 1px solid var(--border-color); /* Add bottom border to separate stacked buttons */
            }
            .telemetry-popup {
                width: 95%; /* Full width on mobile */
                margin: 10px; /* Add margin around mobile popup */
                border-radius: 8px;
                padding: 15px;
            }
            .popup-content {
                max-height: 80vh; /* Adjust content max height for mobile */
                padding: 10px 0;
            }
            #leaderBoard .driver-item .name {
                padding-right: 5px !important;
            }
            .driver-status {
                font-size: 10px;
                padding: 1px 4px;
                margin-left: 4px;
            }
            .telemetry-settings, .stats-history, .recent-races {
                margin: 8px;
                padding: 12px;
                /* Removed max-height and overflow-y from panel styles */
            }
            .settings-title, .history-title, .races-title {
                font-size: 14px;
            }
        }
        .settings-header, .history-header, .races-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            background: var(--background-light); /* Slightly lighter header background */
            padding: 10px 15px;
            border-radius: 8px 8px 0 0; /* Rounded top corners for headers */
        }
        .settings-title, .history-title, .races-title {
            font-size: 1.1em;
            font-weight: bold;
            color: var(--primary-color);
            display: flex;
            align-items: center;
            gap: 8px;
        }
        .toggle-btn, .reset-btn, .toggle-telemetry-btn, .copy-btn { /* Keep existing styles for other buttons */
            background: var(--background-light);
            border: none;
            border-radius: 6px;
            color: var(--text-color);
            padding: 8px 16px;
            cursor: pointer;
            transition: background-color 0.2s ease, color 0.2s ease;
            font-size: 14px;
            margin-top: 5px; /* Added a little top margin for spacing */
        }
        .toggle-btn:hover, .reset-btn:hover, .toggle-telemetry-btn:hover, .copy-btn:hover {
            background-color: var(--accent-color);
            color: var(--background-dark);
        }
        .reset-system-btn { /* New style for Reset System Button */
            background: #f44336; /* Red background */
            border: none; /* No border */
            border-radius: 6px; /* Inherit border-radius */
            color: #fff; /* Inherit text color */
            padding: 8px 16px; /* Inherit padding */
            cursor: pointer; /* Inherit cursor */
            transition: all 0.2s; /* Inherit transition */
            font-size: 14px; /* Inherit font-size */
        }
        .reset-system-btn:hover {
             background-color: #d32f2f; /* Darker red on hover */
         }

        .settings-content, .history-content, .races-content {
            display: flex; /* Use flex to arrange setting groups vertically */
            flex-direction: column;
            gap: 15px; /* Increased gap between setting groups */
            padding: 15px;
            background: var(--background-dark); /* Match popup background */
            border-radius: 0 0 8px 8px; /* Rounded bottom corners for content */
        }
        .setting-group {
            display: flex; /* Flex to stack setting items */
            flex-direction: column;
            gap: 10px; /* Gap between setting items */
            padding: 10px 15px;
            background: var(--background-light); /* Lighter background for setting groups */
            border-radius: 8px;
            border: 1px solid var(--border-color); /* Added border to setting groups */
        }
        .setting-item {
            display: flex;
            flex-direction: column; /* Stack title and control vertically */
            align-items: stretch; /* Stretch items to full width */
            justify-content: flex-start;
            padding: 12px;
            background: var(--background-dark); /* Darker background for individual settings */
            border-radius: 6px;
        }
        .setting-item > span {
            margin-bottom: 8px; /* More space below setting titles */
            font-weight: normal; /* Slightly thinner font for titles */
            color: var(--text-color);
        }
        .switch {
            position: relative;
            display: inline-block; /* Ensure switch is inline-block */
            width: 45px; /* Slightly wider switch */
            height: 24px; /* Slightly taller switch */
        }
        .switch input {
            opacity: 0;
            width: 0;
            height: 0;
        }
        .slider {
            position: absolute;
            cursor: pointer;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            background: #4d4d4d; /* Darker default slider background */
            transition: .3s; /* Slightly slower transition */
            border-radius: 12px; /* More rounded slider */
        }
        .slider:before {
            position: absolute;
            content: "";
            height: 20px; /* Slightly smaller slider handle */
            width: 20px;
            left: 3px;
            bottom: 2px;
            background: #f4f4f4; /* Lighter handle color */
            transition: .3s;
            border-radius: 50%;
        }
        input:checked + .slider {
            background: var(--primary-color); /* Primary color when checked */
        }
        input:checked + .slider:before {
            transform: translateX(21px); /* Adjusted handle movement */
        }
        .radio-group {
            display: flex; /* Keep flex for horizontal radio groups if still used elsewhere */
            gap: 10px;
            padding: 8px;
            background: #252525;
            border-radius: 6px;
        }
        .radio-item {
            display: flex;
            align-items: center;
            gap: 8px;
            padding: 8px 12px;
            background: #333;
            border-radius: 4px;
            cursor: pointer;
            margin: 5px 0; /* Add vertical margin between radio items */
        }
        /* Target the text inside radio-item labels specifically */
        .radio-item label {
            text-align: left; /* Explicitly left align text within radio-item labels */
            display: block; /* Ensure label takes full width for text alignment to work */
            width: 100%; /* Ensure label takes full width */
        }
        .radio-item:first-child {
            margin-top: 0; /* Remove top margin for the first item */
        }
        .api-key-input {
            width: 100%; /* Full width input */
            padding: 0px; /* Increased input padding */
            border-radius: 6px; /* Rounded corners for input */
            border: 1px solid var(--border-color);
            background: var(--background-light);
            color: var(--text-color);
            font-size: 14px;
        }
        .api-key-input:focus {
            outline: none;
            border-color: var(--accent-color); /* Accent color on focus */
            box-shadow: 0 0 5px rgba(var(--accent-color-rgb), 0.5); /* Subtle shadow on focus - requires defining --accent-color-rgb if you use named colors */
        }


        .history-entry, .race-entry {
            padding: 15px; /* Increased padding for entries */
            background: var(--background-light);
            border-radius: 6px;
            margin-bottom: 8px; /* Spacing between entries */
            font-size: 14px;
            line-height: 1.5;
            border: 1px solid var(--border-color); /* Added border to entries */
        }
        .history-entry:last-child, .race-entry:last-child {
            margin-bottom: 0; /* No bottom margin for the last entry */
        }
        .current-stats {
            padding: 15px;
            background: var(--background-light);
            border-radius: 6px;
            margin-bottom: 15px;
            border: 1px solid var(--border-color);
        }
        .current-stats h3 {
            color: var(--primary-color);
            margin-top: 0;
            margin-bottom: 10px;
            font-size: 1.2em;
        }
        .current-stats p {
            margin: 5px 0;
        }
        .setting-dropdown { /* NEW: Style for dropdowns */
            width: 100%;
            padding: 10px;
            border-radius: 6px;
            border: 1px solid var(--border-color);
            background: var(--background-light);
            color: var(--text-color);
            font-size: 14px;
            appearance: none; /* Remove default appearance */
            -webkit-appearance: none; /* For Safari */
            -moz-appearance: none; /* For Firefox */
            background-image: url('data:image/svg+xml;utf8,<svg fill="white" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M7 10l5 5 5-5z"/><path d="M0 0h24v24H0z" fill="none"/></svg>');
            background-repeat: no-repeat;
            background-position-x: 98%; /* Position arrow more to the right */
            background-position-y: 12px; /* Vertically center arrow */
            padding-right: 30px; /* Increased padding for arrow */
        }

        .setting-dropdown::-ms-expand {
            display: none; /*For IE and Edge*/
        }
        .setting-dropdown:focus {
            outline: none;
            border-color: var(--accent-color); /* Accent color on focus */
            box-shadow: 0 0 5px rgba(var(--accent-color-rgb), 0.5); /* Subtle shadow on focus */
        }
    `);

    // Easing function: easeInOutQuad ramps up then down.
    function easeInOutQuad(t) {
        return t < 0.5 ? 2*t*t : -1+(4-2*t)*t;
    }

    // Helper to interpolate between two colors.
    function interpolateColor(color1, color2, factor) {
        const result = color1.map((c, i) => Math.round(c + factor * (color2[i] - c)));
        return `rgb(${result[0]}, ${result[1]}, ${result[2]})`;
    }

    // Returns a color based on acceleration (in g's). If color coding is disabled, always return grey.
    function getTelemetryColor(acceleration) {
        const grey = [136, 136, 136];
        if (!config.colorCode) return `rgb(${grey[0]}, ${grey[1]}, ${grey[2]})`;
        const green = [76, 175, 80];
        const red = [244, 67, 54];
        const maxAcc = 1.0;
        let factor = Math.min(Math.abs(acceleration) / maxAcc, 1);
        if (acceleration > 0) {
            return interpolateColor(grey, green, factor);
        } else if (acceleration < 0) {
            return interpolateColor(grey, red, factor);
        } else {
            return `rgb(${grey[0]}, ${grey[1]}, ${grey[2]})`;
        }
    }

    // Animate telemetry text using an easeInOut function over a dynamic duration.
    function animateTelemetry(element, fromSpeed, toSpeed, fromAcc, toAcc, duration, displayMode, speedUnit, extraText) {
        let startTime = null;
        function step(timestamp) {
            if (!startTime) startTime = timestamp;
            let linearProgress = (timestamp - startTime) / duration;
            if (linearProgress > 1) linearProgress = 1;
            let progress = easeInOutQuad(linearProgress);
            let currentSpeed = fromSpeed + (toSpeed - fromSpeed) * progress;
            let currentAcc = fromAcc + (toAcc - fromAcc) * progress;
            element._currentSpeed = currentSpeed;
            element._currentAcc = currentAcc;
            let color = config.colorCode ? getTelemetryColor(currentAcc) : 'rgb(136, 136, 136)';
            let text;
            if (displayMode === 'speed') {
                text = `${Math.round(currentSpeed)} ${speedUnit}`;
            } else if (displayMode === 'acceleration') {
                text = `${currentAcc.toFixed(1)} g`;
            } else {
                text = `${Math.round(currentSpeed)} ${speedUnit} | ${currentAcc.toFixed(1)} g`;
            }
            text += extraText;
            element.textContent = text;
            element.style.color = color;
            if (linearProgress < 1) {
                element._telemetryAnimationFrame = requestAnimationFrame(step);
            } else {
                element._telemetryAnimationFrame = null;
            }
        }
        element._telemetryAnimationFrame = requestAnimationFrame(step);
    }

    // Function to update the settings UI with the current config
    function updateSettingsUI(popupContent) {
        popupContent.querySelectorAll('input, select').forEach(el => {
            if (el.type === 'checkbox') el.checked = config[el.dataset.setting];
            else if (el.tagName === 'SELECT') el.value = config[el.dataset.setting]; // Handle dropdowns
            else if (el.type === 'radio') el.checked = config[el.name] === el.value;
            else if (el.type === 'number') el.value = config[el.dataset.setting];
            else if (el.classList.contains('api-key-input')) el.value = config[el.dataset.setting];
        });
    }

    function createPopupContainer(title, contentGenerator, popupClass) {
        const container = document.createElement('div');
        container.className = 'telemetry-popup-container ' + popupClass;
        container.innerHTML = `
            <div class="telemetry-popup">
                <div class="popup-header">
                    <span class="popup-title">${title}</span>
                    <button class="close-button">Close</button>
                </div>
                <div class="popup-content">
                    ${contentGenerator ? contentGenerator() : ''}
                </div>
            </div>
        `;
        container.querySelector('.close-button').addEventListener('click', () => {
            container.remove();
        });
        return container;
    }

    // Create the telemetry settings popup.
    function createSettingsPopup() {
        const popupContainer = createPopupContainer("RACING TELEMETRY SETTINGS", generateSettingsContent, 'settings-popup');
        const popupContent = popupContainer.querySelector('.popup-content');
        updateSettingsUI(popupContent);

        const settingsInputs = popupContent.querySelectorAll('input, select');
        settingsInputs.forEach(el => {
            if (el) {
                el.addEventListener('change', () => {
                    if (el.type === 'checkbox') config[el.dataset.setting] = el.checked;
                    else if (el.tagName === 'SELECT') config[el.dataset.setting] = el.value;
                    else if (el.type === 'radio') config[el.name] = el.value;
                    else if (el.type === 'number') config[el.dataset.setting] = parseInt(el.value, 10);
                    else if (el.classList.contains('api-key-input')) config[el.dataset.setting] = el.value;

                    GM_setValue('racingTelemetryConfig', JSON.stringify(config));

                    if (el.dataset.setting === 'apiKey') {
                        dataManager.fetchRacingStats().then(dataManager.fetchRaces.bind(dataManager));
                    }
                });
            }
        });

        const resetButton = popupContent.querySelector('.reset-system-btn');
        if (resetButton) {
            resetButton.addEventListener('click', () => {
                const apiKey = config.apiKey;
                localStorage.clear();
                localStorage.setItem('racingTelemetryConfig', JSON.stringify({ apiKey: apiKey }));
                window.location.reload();
            });
        }

        const copyButton = popupContent.querySelector('.copy-btn');
        if (copyButton) {
            copyButton.addEventListener('click', () => {
                const container = document.getElementById('racingMainContainer');
                if (container) {
                    navigator.clipboard.writeText(container.outerHTML)
                        .then(() => alert('HTML copied to clipboard!'))
                        .catch(err => alert('Failed to copy HTML: ' + err));
                } else {
                    alert('racingMainContainer not found.');
                }
            });
        }

        const telemetryToggleButton = popupContent.querySelector('.toggle-telemetry-btn');
        if (telemetryToggleButton) {
            telemetryToggleButton.addEventListener('click', () => {
                telemetryVisible = !telemetryVisible;
                document.body.classList.toggle('telemetry-hidden', !telemetryVisible);
                telemetryToggleButton.textContent = telemetryVisible ? 'Hide Telemetry' : 'Show Telemetry';
            });
        }


        return popupContainer;
    }


    function generateSettingsContent() {
        return `
            <div class="settings-content">
                <div class="setting-group">
                    <div class="setting-item" title="Select data to display">
                        <span>Display Mode</span>
                        <select class="setting-dropdown" data-setting="displayMode">
                            <option value="speed" ${config.displayMode === 'speed' ? 'selected' : ''}>Speed</option>
                            <option value="acceleration" ${config.displayMode === 'acceleration' ? 'selected' : ''}>Acceleration</option>
                            <option value="both" ${config.displayMode === 'both' ? 'selected' : ''}>Both</option>
                        </select>
                    </div>
                    <div class="setting-item" title="Color-code acceleration status">
                        <span>Color Coding</span>
                        <label class="switch"><input type="checkbox" ${config.colorCode ? 'checked' : ''} data-setting="colorCode"><span class="slider"></span></label>
                    </div>
                    <div class="setting-item" title="Enable smooth animations">
                        <span>Animations</span>
                        <label class="switch"><input type="checkbox" ${config.animateChanges ? 'checked' : ''} data-setting="animateChanges"><span class="slider"></span></label>
                    </div>
                    <div class="setting-item" title="Speed unit preference">
                        <span>Speed Unit</span>
                        <select class="setting-dropdown" data-setting="speedUnit">
                            <option value="mph" ${config.speedUnit === 'mph' ? 'selected' : ''}>mph</option>
                            <option value="kmh" ${config.speedUnit === 'kmh' ? 'selected' : ''}>km/h</option>
                        </select>
                    </div>
                </div>
                <div class="setting-group">
                    <div class="setting-item" title="Your Torn API key for fetching race data">
                        <span>API Key</span>
                        <input type="password" class="api-key-input" value="${config.apiKey}" data-setting="apiKey" placeholder="Enter API Key">
                    </div>
                </div>
                <div class="setting-group">
                    <button class="reset-system-btn">Reset System</button>
                    <button class="copy-btn">Copy HTML</button>
                    <button class="toggle-telemetry-btn">${telemetryVisible ? 'Hide Telemetry' : 'Show Telemetry'}</button>
                </div>
            </div>
        `;
    }

    // Create stats history popup
    function createStatsPopup() {
        const popupContainer = createPopupContainer("PERSONAL STATS HISTORY", generateStatsContent, 'stats-history-popup');
        const popupContent = popupContainer.querySelector('.popup-content');
        popupContent.innerHTML = generateStatsContent(); // Initial content

        return popupContainer;
    }

    // Generate content for stats history panel (same as before)
    function generateStatsContent() {
        let content = '';
        if (state.racingStats?.racing) {
            const currentRacingStats = state.racingStats.racing;
            content += `
                <div class="current-stats">
                    <h3>Current Racing Stats</h3>
                    <p>Skill: ${currentRacingStats.skill || 'N/A'}</p>
                    <p>Points: ${currentRacingStats.points || 'N/A'}</p>
                    <p>Races Entered: ${currentRacingStats.races?.entered || 'N/A'}</p>
                    <p>Races Won: ${currentRacingStats.races?.won || 'N/A'}</p>
                </div>
            `;
        }

        content += state.statsHistory.map(entry => {
            return `
                <div class="history-entry">
                    Time: ${utils.formatTimestamp(entry.timestamp)}
                    Skill: ${entry.skill.old} → ${entry.skill.new}
                    Points: ${entry.points.old} → ${entry.points.new}
                    Races Entered: ${entry.racesEntered.old} → ${entry.racesEntered.new}
                    Races Won: ${entry.racesWon.old} → ${entry.racesWon.new}
                </div>
            `;
        }).join('');

        return content || '<div class="history-entry">No stats history available</div>';
    }

    // Create recent races popup
    function createRacesPopup() {
         const popupContainer = createPopupContainer("RECENT RACES (LAST 10)", generateRacesContent, 'recent-races-popup');
         const popupContent = popupContainer.querySelector('.popup-content');
         popupContent.innerHTML = generateRacesContent(); // Initial content
         return popupContainer;
    }

    // Generate content for recent races panel (same as before)
    function generateRacesContent() {
        state.raceLog.sort((a, b) => {
            const startTimeA = a.schedule?.start ? new Date(a.schedule.start).getTime() : 0;
            const startTimeB = b.schedule?.start ? new Date(b.schedule.start).getTime() : 0;
            return startTimeB - startTimeA;
        });

        return state.raceLog.map(race => {
            return `
                <div class="race-entry">
                    ID: ${race.id || 'N/A'}
                    Title: ${race.title || 'N/A'}
                    Track ID: ${race.track_id || 'N/A'}
                    Status: ${race.status || 'N/A'}
                    Laps: ${race.laps || 'N/A'}
                    Participants: ${race.participants?.current || 0}/${race.participants?.maximum || 0}
                    Start Time: ${race.schedule?.start ? utils.formatTimestamp(race.schedule.start) : 'N/A'}
                </div>
            `;
        }).join('') || '<div class="race-entry">No recent races found</div>';
    }


    function calculateDriverMetrics(driverId, progressPercentage, timestamp) {
        const prev = state.previousMetrics[driverId] || {
            progress: progressPercentage,
            time: timestamp,
            instantaneousSpeed: 0,
            reportedSpeed: 0,
            acceleration: 0,
            lastDisplayedSpeed: 0,
            lastDisplayedAcceleration: 0,
            firstUpdate: true
        };
        let dt = (timestamp - prev.time) / 1000;
        const minDt = config.minUpdateInterval / 1000;
        const effectiveDt = dt < minDt ? minDt : dt;
        if (dt <= 0) {
            state.previousMetrics[driverId] = prev;
            return { speed: prev.reportedSpeed, acceleration: prev.acceleration, timeDelta: effectiveDt };
        }
        const distanceDelta = state.trackInfo.total * (progressPercentage - prev.progress) / 100;
        const currentInstantaneousSpeed = (distanceDelta / effectiveDt) * 3600;
        let averagedSpeed;
        if (prev.firstUpdate) {
            averagedSpeed = currentInstantaneousSpeed;
        } else {
            averagedSpeed = (prev.instantaneousSpeed + currentInstantaneousSpeed) / 2;
        }
        let acceleration;
        if (prev.firstUpdate) {
            acceleration = 0;
        } else {
            acceleration = ((averagedSpeed - prev.reportedSpeed) / effectiveDt) * 0.44704 / 9.81;
        }
        state.previousMetrics[driverId] = {
            progress: progressPercentage,
            time: timestamp,
            instantaneousSpeed: currentInstantaneousSpeed,
            reportedSpeed: averagedSpeed,
            acceleration: acceleration,
            lastDisplayedSpeed: prev.lastDisplayedSpeed || averagedSpeed,
            lastDisplayedAcceleration: prev.lastDisplayedAcceleration || acceleration,
            firstUpdate: false
        };
        return { speed: Math.abs(averagedSpeed), acceleration: acceleration, timeDelta: effectiveDt };
    }

    function updateDriverDisplay(driverElement, percentageText, progressPercentage) {
        try {
            const driverId = driverElement?.id;
            if (!driverId) return;
            const now = Date.now();
            if (now - (lastUpdateTimes[driverId] || 0) < config.minUpdateInterval) return;
            lastUpdateTimes[driverId] = now;
            const nameEl = driverElement.querySelector('.name');
            const timeEl = driverElement.querySelector('.time');
            const statusEl = driverElement.querySelector('.status-wrap div');
            if (!nameEl || !timeEl || !statusEl) return;
            let statusText = nameEl.querySelector('.driver-status') || document.createElement('span');
            statusText.className = 'driver-status';
            if (!statusText.parentElement) nameEl.appendChild(statusText);

            const infoSpot = document.getElementById('infoSpot');
            if (infoSpot && infoSpot.textContent.trim().toLowerCase() === 'race starting') { // More specific check
                statusText.textContent = '🛑 NOT STARTED';
                statusText.style.color = 'rgb(136, 136, 136)';
                return; // Early return if race is explicitly starting, thus NOT STARTED
            }


            // Check if ANY driver has started reporting time.  This is a more reliable start indicator.
            let raceHasBegun = false;
            const allDriverTimes = document.querySelectorAll('#leaderBoard li[id^="lbr-"] .time');
            for (const timeElement of allDriverTimes) {
                if (timeElement.textContent.trim() && timeElement.textContent.trim() !== '0%') {
                    raceHasBegun = true;
                    break;
                }
            }

            if (!raceHasBegun && !(infoSpot && infoSpot.textContent.trim().toLowerCase() === 'race finished')) { // Refine raceHasBegun check
                statusText.textContent = '🛑 NOT STARTED';
                statusText.style.color = 'rgb(136, 136, 136)';
                return; // Keep NOT STARTED if no driver has time and infoSpot isn't 'start' and NOT finished
            }


            const isFinished = ['finished', 'gold', 'silver', 'bronze'].some(cls => statusEl.classList.contains(cls));
            if (isFinished) {
                const finishTime = utils.parseTime(timeEl.textContent);
                const avgSpeed = finishTime > 0 ? (state.trackInfo.total / finishTime) * 3600 : 0;
                const avgSpeedFormatted = Math.round(utils.convertSpeed(avgSpeed, config.speedUnit));
                statusText.textContent = `🏁 ${avgSpeedFormatted} ${config.speedUnit}`; // Display average speed on finish
                statusText.style.color = 'rgb(136, 136, 136)';
            } else {
                const metrics = calculateDriverMetrics(driverId, progressPercentage, now);
                const targetSpeed = Math.round(utils.convertSpeed(metrics.speed, config.speedUnit));
                const targetSpeedFormatted = targetSpeed.toLocaleString(undefined, { maximumFractionalDigits: 0 });
                let extraText = "";
                if (driverElement.classList.contains('selected')) {
                    const pdLapEl = document.querySelector('#racingdetails .pd-lap');
                    if (pdLapEl) {
                        const [currentLap, totalLaps] = pdLapEl.textContent.split('/').map(Number);
                        const lapPercentage = 100 / totalLaps;
                        const progressInLap = (progressPercentage - (currentLap - 1) * lapPercentage) / lapPercentage * 100;
                        const remainingDistance = state.trackInfo.length * (1 - progressInLap / 100);
                        if (metrics.speed > 0) {
                            const estTime = (remainingDistance / metrics.speed) * 3600;
                            extraText = ` | Est. Lap: ${utils.formatTime(estTime)}`;
                        }
                    }
                }
                if (config.animateChanges) {
                    if (statusText._telemetryAnimationFrame) {
                        cancelAnimationFrame(statusText._telemetryAnimationFrame);
                        statusText._telemetryAnimationFrame = null;
                    }
                    let fromSpeed = (statusText._currentSpeed !== undefined) ? statusText._currentSpeed : state.previousMetrics[driverId].lastDisplayedSpeed;
                    let fromAcc = (statusText._currentAcc !== undefined) ? statusText._currentAcc : state.previousMetrics[driverId].lastDisplayedAcceleration;
                    let toSpeed = targetSpeed;
                    let toAcc = metrics.acceleration;
                    let duration = metrics.timeDelta * 1000;
                    animateTelemetry(statusText, fromSpeed, toSpeed, fromAcc, toAcc, duration, config.displayMode, config.speedUnit, extraText);
                    state.previousMetrics[driverId].lastDisplayedSpeed = toSpeed;
                    state.previousMetrics[driverId].lastDisplayedAcceleration = toAcc;
                } else {
                    let text;
                    if (config.displayMode === 'speed') {
                        text = `${targetSpeedFormatted} ${config.speedUnit}`;
                    } else if (displayMode === 'acceleration') {
                        text = `${metrics.acceleration.toFixed(1)} g`;
                    } else {
                        text = `${targetSpeedFormatted} ${config.speedUnit} | ${metrics.acceleration.toFixed(1)} g`;
                    }
                    text += extraText;
                    statusText.textContent = text;
                    statusText.style.color = config.colorCode ? getTelemetryColor(metrics.acceleration) : 'rgb(136, 136, 136)';
                }
            }
        } catch (e) {
            console.error('Driver display update failed:', e);
        }
    }


    function resetRaceState() {
        state.previousMetrics = {};
        state.trackInfo = { total: 0, laps: 0, length: 0 };
        raceStarted = false;
        clearInterval(periodicCheckIntervalId);
        periodicCheckIntervalId = null;
        observers.forEach(obs => obs.disconnect());
        observers = [];
        lastUpdateTimes = {};
        const container = document.querySelector('.cont-black');
        if (container) {
            const telemetryButtonContainer = container.querySelector('#telemetryButtonContainer');
            if (telemetryButtonContainer) {
                telemetryButtonContainer.remove();
            }
        }
    }

    function setupPeriodicCheck() {
        if (periodicCheckIntervalId) return;
        periodicCheckIntervalId = setInterval(() => {
            try {
                document.querySelectorAll('#leaderBoard li[id^="lbr-"]').forEach(driverEl => {
                    const timeEl = driverEl.querySelector('.time');
                    if (!timeEl) return;
                    const text = timeEl.textContent.trim();
                    const progress = utils.parseProgress(text);
                    updateDriverDisplay(driverEl, text, progress);
                });
            } catch (e) {
                console.error('Periodic check error:', e);
            }
        }, config.periodicCheckInterval); // Now using the hardcoded interval
    }

    function observeDrivers() {
        observers.forEach(obs => obs.disconnect());
        observers = [];
        const drivers = document.querySelectorAll('#leaderBoard li[id^="lbr-"]');
        drivers.forEach(driverEl => {
            const timeEl = driverEl.querySelector('.time');
            if (!timeEl) return;
            updateDriverDisplay(driverEl, timeEl.textContent || '0%', utils.parseProgress(timeEl.textContent || '0%'));
            const observer = new MutationObserver(() => {
                try {
                    const text = timeEl.textContent || '0%';
                    const progress = utils.parseProgress(text);
                    if (progress !== state.previousMetrics[driverEl.id]?.progress) {
                        updateDriverDisplay(driverEl, text, progress);
                    }
                } catch (e) {
                    console.error('Mutation observer error:', e);
                }
            });
            observer.observe(timeEl, { childList: true, subtree: true, characterData: true });
            observers.push(observer);
        });
    }

    function initializeLeaderboard() {
        const leaderboard = document.getElementById('leaderBoard');
        if (!leaderboard) return;
        if (leaderboard.children.length) {
            observeDrivers();
            setupPeriodicCheck();
        } else {
            new MutationObserver((_, obs) => {
                if (leaderboard.children.length) {
                    observeDrivers();
                    setupPeriodicCheck();
                    obs.disconnect();
                }
            }).observe(leaderboard, { childList: true });
        }
    }

    function updateTrackInfo() {
        try {
            const trackHeader = document.querySelector('.drivers-list .title-black');
            if (!trackHeader) throw new Error('Track header missing');
            const parentElement = trackHeader.parentElement;
            if (!parentElement) throw new Error('Track header parent missing');
            const infoElement = parentElement.querySelector('.track-info');
            const lapsMatch = trackHeader.textContent.match(/(\d+)\s+laps?/i);
            const lengthMatch = infoElement?.dataset.length?.match(/(\d+\.?\d*)/);
            state.trackInfo = {
                laps: lapsMatch ? parseInt(lapsMatch[1]) : 5,
                length: lengthMatch ? parseFloat(lengthMatch[1]) : 3.4,
                get total() { return this.laps * this.length; }
            };
        } catch (e) {
            state.trackInfo = { laps: 5, length: 3.4, total: 17 };
        }
    }

    function initializeUI(container) {
        const buttonContainer = document.createElement('div');
        buttonContainer.id = 'telemetryButtonContainer';
        buttonContainer.className = 'telemetry-button-container';

        const settingsButton = document.createElement('div');
        settingsButton.className = 'telemetry-button';
        settingsButton.textContent = 'Settings';
        settingsButton.addEventListener('click', () => {
            document.body.appendChild(createSettingsPopup());
        });

        const statsButton = document.createElement('div');
        statsButton.className = 'telemetry-button';
        statsButton.textContent = 'Stats History';
        statsButton.addEventListener('click', () => {
            document.body.appendChild(createStatsPopup());
            dataManager.fetchRacingStats(); // Refresh data when popup is opened
        });

        const racesButton = document.createElement('div');
        racesButton.className = 'telemetry-button';
        racesButton.textContent = 'Recent Races';
        racesButton.addEventListener('click', () => {
            document.body.appendChild(createRacesPopup());
            dataManager.fetchRaces(); // Refresh data when popup is opened
        });


        buttonContainer.appendChild(settingsButton);
        buttonContainer.appendChild(statsButton);
        buttonContainer.appendChild(racesButton);

        container.prepend(buttonContainer);
    }


    function initialize() {
        try {
            const container = document.querySelector('.cont-black');
            if (!container) throw new Error('Container not found');
            const raceId = window.location.href.match(/sid=racing.*?(?=&|$)/)?.[0] || 'default';
            if (currentRaceId !== raceId) {
                resetRaceState();
                currentRaceId = raceId;
            }
            if (container.querySelector('#telemetryButtonContainer')) {
                updateTrackInfo();
                initializeLeaderboard();
                return;
            }

            initializeUI(container);

            updateTrackInfo();
            initializeLeaderboard();
            dataManager.fetchRacingStats().then(dataManager.fetchRaces.bind(dataManager));
        } catch (e) {
            console.error('Initialization failed:', e);
        }
    }

    const racingUpdatesObserver = new MutationObserver((mutations) => {
        mutations.forEach(mutation => {
            mutation.addedNodes.forEach(node => {
                if (node.nodeType === 1 && node.id === 'racingupdates') {
                    initialize();
                }
            });
            mutation.removedNodes.forEach(node => {
                if (node.nodeType === 1 && node.id === 'racingupdates') {
                    resetRaceState();
                }
            });
        });
    });

    racingUpdatesObserver.observe(document.body, { childList: true, subtree: true });
    document.readyState === 'complete' ? initialize() : window.addEventListener('load', initialize);
    window.addEventListener('popstate', initialize);
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址