// ==UserScript==
// @name Torn Racing Telemetry
// @namespace https://www.torn.com/profiles.php?XID=2782979
// @version 2.4.1
// @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;
return timeString.split(':').reduce((acc, val, idx) => acc + (parseFloat(val) * Math.pow(60, idx)), 0);
},
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 .history-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 .races-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;
}
.telemetry-settings, .stats-history, .recent-races {
background: var(--background-dark);
border: 1px solid var(--border-color);
border-radius: 8px;
margin: 15px;
padding: 15px;
color: var(--text-color);
font-family: 'Arial', sans-serif;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
/* Removed max-height and overflow-y from panel styles */
}
#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) {
#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: #252525;
padding: 8px;
border-radius: 6px;
}
.settings-title, .history-title, .races-title {
font-size: 16px;
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: #333;
border: 1px solid var(--border-color);
border-radius: 5px;
color: #fff;
padding: 6px 12px;
cursor: pointer;
transition: all 0.2s;
font-size: 12px;
}
.reset-system-btn { /* New style for Reset System Button */
background: #f44336; /* Red background */
border: 1px solid var(--border-color); /* Inherit border */
border-radius: 5px; /* Inherit border-radius */
color: #fff; /* Inherit text color */
padding: 6px 12px; /* Inherit padding */
cursor: pointer; /* Inherit cursor */
transition: all 0.2s; /* Inherit transition */
font-size: 12px; /* Inherit font-size */
}
.settings-content, .history-content, .races-content {
display: grid;
gap: 12px;
padding: 10px;
background: #202020;
border-radius: 6px;
max-height: 300px; /* Set max height for content area */
overflow-y: auto; /* Enable vertical scrolling */
}
.setting-group {
display: grid;
gap: 8px;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
background: var(--background-light);
border-radius: 4px;
}
.switch {
position: relative;
width: 40px;
height: 22px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #404040;
transition: .2s;
border-radius: 11px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 2px;
bottom: 2px;
background: #fff;
transition: .2s;
border-radius: 50%;
}
input:checked + .slider {
background: var(--primary-color);
}
input:checked + .slider:before {
transform: translateX(18px);
}
.radio-group {
display: flex;
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;
}
.api-key-input {
width: 150px;
padding: 5px;
border-radius: 4px;
border: 1px solid var(--border-color);
background: var(--background-light);
color: var(--text-color);
}
.history-entry, .race-entry {
padding: 10px;
background: var(--background-light);
border-radius: 4px;
white-space: pre-wrap;
}
.current-stats {
padding: 10px;
background: var(--background-light);
border-radius: 4px;
margin-bottom: 10px;
}
`);
// 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(panel) {
panel.querySelectorAll('input, select').forEach(el => {
if (el.type === 'checkbox') el.checked = config[el.dataset.setting];
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];
});
}
// Create the telemetry settings panel.
function createSettingsPanel() {
const panel = document.createElement('div');
panel.className = 'telemetry-settings';
const isOpen = localStorage.getItem('telemetrySettingsOpen') === 'true';
panel.innerHTML = `
<div class="settings-header">
<span class="settings-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--primary-color)">
<path d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97 0-.33-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.50.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1 0 .33.03.65.07.97l-2.11 1.66c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l-.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64L19.43 13Z"/>
</svg>
RACING TELEMETRY
</span>
<button class="toggle-btn">${isOpen ? '▲' : '▼'}</button>
</div>
<div class="settings-content" style="display: ${isOpen ? 'grid' : 'none'}">
<div class="setting-group">
<div class="setting-item" title="Select data to display">
<span>Display Mode</span>
<div class="radio-group">
<label class="radio-item"><input type="radio" name="displayMode" value="speed" ${config.displayMode === 'speed' ? 'checked' : ''}>Speed</label>
<label class="radio-item"><input type="radio" name="displayMode" value="acceleration" ${config.displayMode === 'acceleration' ? 'checked' : ''}>Acceleration</label>
<label class="radio-item"><input type="radio" name="displayMode" value="both" ${config.displayMode === 'both' ? 'checked' : ''}>Both</label>
</div>
</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>
<div class="radio-group">
<label class="radio-item"><input type="radio" name="speedUnit" value="mph" ${config.speedUnit === 'mph' ? 'checked' : ''}>mph</label>
<label class="radio-item"><input type="radio" name="speedUnit" value="kmh" ${config.speedUnit === 'kmh' ? 'checked' : ''}>km/h</label>
</div>
</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>
<button class="reset-system-btn">Reset System</button> <button class="copy-btn" style="margin-top: 10px;">Copy HTML</button>
<button class="toggle-telemetry-btn" style="margin-top: 10px;">${telemetryVisible ? 'Hide Telemetry' : 'Show Telemetry'}</button>
</div>
`;
const toggleButton = panel.querySelector('.toggle-btn');
if (toggleButton) {
toggleButton.addEventListener('click', () => {
const content = panel.querySelector('.settings-content');
const isVisible = content.style.display === 'grid';
content.style.display = isVisible ? 'none' : 'grid';
panel.querySelector('.toggle-btn').textContent = isVisible ? '▼' : '▲';
localStorage.setItem('telemetrySettingsOpen', !isVisible);
});
}
const resetButton = panel.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 settingsInputs = panel.querySelectorAll('input, select');
settingsInputs.forEach(el => {
if (el) {
el.addEventListener('change', () => {
if (el.type === 'checkbox') config[el.dataset.setting] = el.checked;
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 copyButton = panel.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 = panel.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 panel;
}
// Create stats history panel
function createStatsPanel() {
const panel = document.createElement('div');
panel.className = 'stats-history';
const isOpen = localStorage.getItem('statsHistoryOpen') === 'true';
panel.innerHTML = `
<div class="history-header">
<span class="history-title">PERSONAL STATS HISTORY</span>
<button class="toggle-btn">${isOpen ? '▲' : '▼'}</button>
</div>
<div class="history-content" style="display: ${isOpen ? 'grid' : 'none'}">
${generateStatsContent()}
</div>
`;
panel.querySelector('.toggle-btn').addEventListener('click', () => {
const content = panel.querySelector('.history-content');
const isVisible = content.style.display === 'grid';
content.style.display = isVisible ? 'none' : 'grid';
panel.querySelector('.toggle-btn').textContent = isVisible ? '▼' : '▲';
localStorage.setItem('statsHistoryOpen', !isVisible);
});
return panel;
}
// Generate content for stats history panel
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 panel
function createRacesPanel() {
const panel = document.createElement('div');
panel.className = 'recent-races';
const isOpen = localStorage.getItem('recentRacesOpen') === 'true';
panel.innerHTML = `
<div class="races-header">
<span class="races-title">RECENT RACES (LAST 10)</span>
<button class="toggle-btn">${isOpen ? '▲' : '▼'}</button>
</div>
<div class="races-content" style="display: ${isOpen ? 'grid' : 'none'}">
${generateRacesContent()}
</div>
`;
panel.querySelector('.toggle-btn').addEventListener('click', () => {
const content = panel.querySelector('.races-content');
const isVisible = content.style.display === 'grid';
content.style.display = isVisible ? 'none' : 'grid';
panel.querySelector('.toggle-btn').textContent = isVisible ? '▼' : '▲';
localStorage.setItem('recentRacesOpen', !isVisible);
});
return panel;
}
// Generate content for recent races panel
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 (config.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 telemetryContainer = container.querySelector('#tornRacingTelemetryContainer');
if (telemetryContainer) {
telemetryContainer.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 telemetryContainer = document.createElement('div');
telemetryContainer.id = 'tornRacingTelemetryContainer';
container.prepend(telemetryContainer);
const settingsPanel = createSettingsPanel();
telemetryContainer.append(
settingsPanel,
createStatsPanel(),
createRacesPanel()
);
updateSettingsUI(settingsPanel);
}
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('#tornRacingTelemetryContainer')) {
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);
})();