// ==UserScript==
// @name Torn Racing Telemetry
// @namespace https://www.torn.com/profiles.php?XID=2782979
// @version 2.5.0
// @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;
// Handle different timestamp formats
if (typeof timestamp === 'number') {
// If timestamp is a small number (seconds instead of milliseconds)
if (timestamp < 10000000000) {
date = new Date(timestamp * 1000);
} else {
date = new Date(timestamp);
}
} else if (typeof timestamp === 'string') {
// Try to parse string timestamp
if (timestamp.match(/^\d+$/)) {
// If it's all digits, treat as a number
const num = parseInt(timestamp, 10);
if (num < 10000000000) {
date = new Date(num * 1000);
} else {
date = new Date(num);
}
} else {
// Otherwise just parse as date string
date = new Date(timestamp);
}
} else {
return 'N/A';
}
if (isNaN(date) || date.getFullYear() < 2000) {
return 'Invalid Date';
}
// Format as a more readable date/time string
const options = {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
};
return date.toLocaleDateString(undefined, options);
},
// New utility for formatting change values
formatChange: (oldVal, newVal) => {
const change = newVal - oldVal;
if (change > 0) {
return `<span style="color: #4CAF50;">${oldVal} → ${newVal} (+${change})</span>`;
} else if (change < 0) {
return `<span style="color: #f44336;">${oldVal} → ${newVal} (${change})</span>`;
} else {
return `${oldVal} → ${newVal} (no change)`;
}
}
};
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 */
--highlight-color: #FFD54F; /* For highlighting key stats */
--positive-color: #4CAF50; /* For positive changes */
--negative-color: #f44336; /* For negative changes */
}
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 */
}
/* Enhanced styles for the racing panels */
.race-entry {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px; /* Space between grid items */
}
.race-entry .race-header {
grid-column: 1 / -1; /* Span all columns */
border-bottom: 1px solid var(--border-color);
padding-bottom: 8px;
margin-bottom: 8px;
}
.race-entry .race-title {
font-weight: bold;
font-size: 1.1em;
}
.race-entry .race-title a {
color: var(--primary-color);
text-decoration: none;
transition: color 0.2s ease;
}
.race-entry .race-title a:hover {
text-decoration: underline;
color: var(--accent-color);
}
.race-entry .race-id {
color: #999;
font-size: 0.9em;
}
.race-entry .race-info {
display: flex;
flex-direction: column;
gap: 6px;
}
.race-entry .race-detail {
display: flex;
align-items: center;
}
.race-entry .race-detail-label {
font-weight: bold;
width: 100px; /* Fixed width for labels */
color: var(--text-color);
}
.race-entry .race-detail-value {
flex: 1;
}
.race-entry .race-status {
padding: 2px 8px;
border-radius: 3px;
display: inline-block;
text-transform: capitalize;
}
.race-entry .race-status.finished {
background-color: var(--primary-color);
color: white;
}
/* Stats history enhancements */
.history-entry {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.history-entry .history-time {
grid-column: 1 / -1;
font-weight: bold;
border-bottom: 1px solid var(--border-color);
padding-bottom: 5px;
margin-bottom: 5px;
color: var(--accent-color);
}
.history-entry .stat-change {
display: flex;
flex-direction: column;
margin-bottom: 5px;
}
.history-entry .stat-label {
font-weight: bold;
color: var(--text-color);
}
.history-entry .stat-value {
font-family: monospace;
}
.history-entry .stat-value .increase {
color: var(--positive-color);
}
.history-entry .stat-value .decrease {
color: var(--negative-color);
}
/* Current stats section in stats history */
.current-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.current-stats h3 {
grid-column: 1 / -1;
text-align: center;
border-bottom: 1px solid var(--border-color);
padding-bottom: 10px;
}
.current-stats .stat-item {
display: flex;
flex-direction: column;
align-items: center;
background: var(--background-dark);
padding: 10px;
border-radius: 5px;
}
.current-stats .stat-label {
font-size: 0.9em;
color: var(--text-color);
margin-bottom: 5px;
}
.current-stats .stat-value {
font-size: 1.4em;
font-weight: bold;
color: var(--highlight-color);
}
`);
// 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 (enhanced version)
function generateStatsContent() {
let content = '';
// Current racing stats section
if (state.racingStats?.racing) {
const currentRacingStats = state.racingStats.racing;
content += `
<div class="current-stats">
<h3>Current Racing Stats</h3>
<div class="stat-item">
<div class="stat-label">Racing Skill</div>
<div class="stat-value">${currentRacingStats.skill || '0'}</div>
</div>
<div class="stat-item">
<div class="stat-label">Racing Points</div>
<div class="stat-value">${currentRacingStats.points?.toLocaleString() || '0'}</div>
</div>
<div class="stat-item">
<div class="stat-label">Races Entered</div>
<div class="stat-value">${currentRacingStats.races?.entered?.toLocaleString() || '0'}</div>
</div>
<div class="stat-item">
<div class="stat-label">Races Won</div>
<div class="stat-value">${currentRacingStats.races?.won?.toLocaleString() || '0'}</div>
</div>
</div>
`;
}
// Stats history entries
content += state.statsHistory.map(entry => {
return `
<div class="history-entry">
<div class="history-time">
${utils.formatTimestamp(entry.timestamp)}
</div>
<div class="stat-change">
<div class="stat-label">Racing Skill</div>
<div class="stat-value">${utils.formatChange(entry.skill.old, entry.skill.new)}</div>
</div>
<div class="stat-change">
<div class="stat-label">Racing Points</div>
<div class="stat-value">${utils.formatChange(entry.points.old, entry.points.new)}</div>
</div>
<div class="stat-change">
<div class="stat-label">Races Entered</div>
<div class="stat-value">${utils.formatChange(entry.racesEntered.old, entry.racesEntered.new)}</div>
</div>
<div class="stat-change">
<div class="stat-label">Races Won</div>
<div class="stat-value">${utils.formatChange(entry.racesWon.old, entry.racesWon.new)}</div>
</div>
</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 (enhanced version)
function generateRacesContent() {
// Sort races by start time (newest first)
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 => {
// Format the race status with appropriate styling
const statusClass = race.status ? race.status.toLowerCase() : '';
const statusHTML = `<span class="race-status ${statusClass}">${race.status || 'Unknown'}</span>`;
return `
<div class="race-entry">
<div class="race-header">
<div class="race-title">
<a href="https://www.torn.com/loader.php?sid=racing&tab=log&raceID=${race.id}" target="_self">${race.title || 'Untitled Race'}</a>
</div>
<div class="race-id">ID: ${race.id || 'Unknown'}</div>
</div>
<div class="race-info">
<div class="race-detail">
<div class="race-detail-label">Track:</div>
<div class="race-detail-value">ID ${race.track_id || 'Unknown'}</div>
</div>
<div class="race-detail">
<div class="race-detail-label">Status:</div>
<div class="race-detail-value">${statusHTML}</div>
</div>
<div class="race-detail">
<div class="race-detail-label">Laps:</div>
<div class="race-detail-value">${race.laps || '0'}</div>
</div>
</div>
<div class="race-info">
<div class="race-detail">
<div class="race-detail-label">Participants:</div>
<div class="race-detail-value">${race.participants?.current || '0'}/${race.participants?.maximum || '0'}</div>
</div>
<div class="race-detail">
<div class="race-detail-label">Start Time:</div>
<div class="race-detail-value">${race.schedule?.start ? utils.formatTimestamp(race.schedule.start) : 'N/A'}</div>
</div>
</div>
</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);
})();