init
This commit is contained in:
709
index.html
Normal file
709
index.html
Normal file
@@ -0,0 +1,709 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ACC Championship Leaderboard</title>
|
||||
<!-- Load Tailwind CSS --><script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- Load D3.js --><script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
<style>
|
||||
/* --- Custom Styles --- */
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: #111827; /* gray-900 */
|
||||
}
|
||||
|
||||
/* Custom font loading */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
|
||||
|
||||
/* Custom scrollbar for a cleaner look */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1f2937; /* gray-800 */
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #4b5563; /* gray-600 */
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280; /* gray-500 */
|
||||
}
|
||||
|
||||
/* Podium Styles */
|
||||
.podium-1 {
|
||||
background: linear-gradient(145deg, #fde047, #facc15); /* yellow-400, yellow-500 */
|
||||
color: #422006;
|
||||
font-weight: 700;
|
||||
border-left: 4px solid #f59e0b; /* amber-500 */
|
||||
}
|
||||
.podium-2 {
|
||||
background: linear-gradient(145deg, #e5e7eb, #d1d5db); /* gray-200, gray-300 */
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
border-left: 4px solid #9ca3af; /* gray-400 */
|
||||
}
|
||||
.podium-3 {
|
||||
background: linear-gradient(145deg, #f0c0a0, #e69a6a); /* Custom bronze/orange */
|
||||
color: #431407;
|
||||
font-weight: 600;
|
||||
border-left: 4px solid #c2410c; /* orange-700 */
|
||||
}
|
||||
|
||||
/* Points Table Key Styles (Updated to match image) */
|
||||
.points-p1 {
|
||||
background: linear-gradient(145deg, #fde047, #facc15); /* Copied from .podium-1 */
|
||||
color: #422006; /* Copied from .podium-1 */
|
||||
}
|
||||
.points-p2 {
|
||||
background: linear-gradient(145deg, #e5e7eb, #d1d5db); /* Copied from .podium-2 */
|
||||
color: #1f2937; /* Copied from .podium-2 */
|
||||
}
|
||||
.points-p3 {
|
||||
background: linear-gradient(145deg, #f0c0a0, #e69a6a); /* Copied from .podium-3 */
|
||||
color: #431407; /* Copied from .podium-3 */
|
||||
}
|
||||
.points-p4 { background-color: #BFDBFE; color: #1F2937; } /* Light Blue */
|
||||
.points-p5 { background-color: #374151; color: #E5E7EB; } /* Light Gray Text on Dark Gray BG */
|
||||
.points-fl { background-color: #A855F7; color: #FFFFFF; } /* Purple */
|
||||
|
||||
/* Edit Mode Styles */
|
||||
[contenteditable="true"] {
|
||||
outline: 2px dashed #3b82f6; /* blue-500 */
|
||||
background-color: #1f2937; /* gray-800 */
|
||||
cursor: cell;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
[contenteditable="true"]:hover {
|
||||
background-color: #374151; /* gray-700 */
|
||||
}
|
||||
|
||||
/* Sticky header for table */
|
||||
#race-table-head th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10; /* All header cells */
|
||||
background-color: #111827; /* gray-900 */
|
||||
}
|
||||
/* Sticky first column for table */
|
||||
#race-table-body td:first-child,
|
||||
#race-table-head th:first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 11; /* 1st col TH above driver THs */
|
||||
background-color: #111827; /* gray-900 */
|
||||
min-width: 200px;
|
||||
}
|
||||
#race-table-body td:first-child {
|
||||
z-index: 1; /* 1st col TD below header */
|
||||
background-color: #1f2937; /* Match body bg */
|
||||
}
|
||||
|
||||
/* NEW: Sticky second column */
|
||||
#race-table-body td:nth-child(2),
|
||||
#race-table-head th:nth-child(2) {
|
||||
position: sticky;
|
||||
left: 200px; /* Must match min-width of first col */
|
||||
z-index: 11; /* 2nd col TH above driver THs */
|
||||
background-color: #111827; /* gray-900 */
|
||||
min-width: 100px;
|
||||
font-weight: 600;
|
||||
color: #a855f7; /* Purple-500 for FL */
|
||||
}
|
||||
#race-table-body td:nth-child(2) {
|
||||
z-index: 1; /* 2nd col TD below header */
|
||||
background-color: #1f2937; /* Match body bg */
|
||||
}
|
||||
|
||||
/* D3 Chart Styles */
|
||||
.chart-axis path,
|
||||
.chart-axis line {
|
||||
stroke: #9ca3af; /* gray-400 */
|
||||
}
|
||||
.chart-axis text {
|
||||
fill: #d1d5db; /* gray-300 */
|
||||
font-size: 12px;
|
||||
}
|
||||
.chart-title {
|
||||
fill: #f9fafb; /* gray-50 */
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
text-anchor: middle;
|
||||
}
|
||||
.chart-grid line {
|
||||
stroke: #374151; /* gray-700 */
|
||||
stroke-opacity: 0.7;
|
||||
shape-rendering: crispEdges;
|
||||
}
|
||||
.chart-line {
|
||||
fill: none;
|
||||
/* stroke: #3b82f6; */ /* Color will be set by D3 */
|
||||
stroke-width: 2.5px;
|
||||
}
|
||||
.chart-dot {
|
||||
/* fill: #3b82f6; */ /* Color will be set by D3 */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-200 p-4 md:p-8">
|
||||
|
||||
<div class="container mx-auto max-w-7xl space-y-12">
|
||||
<header class="text-center">
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500 mb-4">
|
||||
ACC Championship Leaderboard
|
||||
</h1>
|
||||
<p class="text-lg text-gray-400">Your central hub for all race results and driver standings.</p>
|
||||
<button id="edit-mode-toggle" class="mt-4 px-6 py-2 bg-blue-600 text-white font-semibold rounded-lg shadow-md hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">
|
||||
Toggle Edit Mode
|
||||
</button>
|
||||
<p id="edit-mode-status" class="text-yellow-400 mt-2 h-4"></p>
|
||||
</header>
|
||||
|
||||
<!-- Main Layout --><main class="space-y-12">
|
||||
|
||||
<!-- Race Results (Full Width) -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Race Results</h2>
|
||||
<!-- Added overflow-x-auto here to handle table growth -->
|
||||
<div id="table-container" class="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50 overflow-x-auto">
|
||||
<table id="race-results-table" class="w-full min-w-[1200px] border-collapse text-center">
|
||||
<thead id="race-table-head">
|
||||
<!-- JS will populate this --></thead>
|
||||
<tbody id="race-table-body" class="divide-y divide-gray-700">
|
||||
<!-- JS will populate this --></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Grid container for Standings & Points Key -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
<!-- Driver Standings (2/3 width) -->
|
||||
<section class="lg:col-span-2">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Driver Standings</h2>
|
||||
<div class="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50">
|
||||
<table class="w-full">
|
||||
<thead class="border-b border-gray-600">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-300">Pos</th>
|
||||
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-300">Driver</th>
|
||||
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-300">Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="standings-body">
|
||||
<!-- JS will populate this --></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Points Table Key (1/3 width) -->
|
||||
<section class="lg:col-span-1">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Points Table:</h2>
|
||||
<div class="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50">
|
||||
<table class="w-full text-center font-bold">
|
||||
<tbody id="points-key-body">
|
||||
<!-- JS will populate this --></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Driver Charts --><section>
|
||||
<h2 class="text-2xl font-bold text-white mb-6 text-center">Cumulative Points per Race</h2>
|
||||
<div id="combined-chart-container" class="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50">
|
||||
<!-- JS will generate the combined chart here --></div>
|
||||
</section>
|
||||
|
||||
<script type-="module">
|
||||
// --- APPLICATION STATE ---
|
||||
let isEditable = false;
|
||||
|
||||
// Points key mapping (Updated to match the new image and values)
|
||||
const pointsKey = {
|
||||
10: { class: "points-p1", label: "1st 10", priority: 1 },
|
||||
7: { class: "points-p2", label: "2nd 7", priority: 2 },
|
||||
5: { class: "points-p3", label: "3rd 5", priority: 3 },
|
||||
2: { class: "points-p4", label: "4th 2", priority: 4 },
|
||||
1: { class: "points-p5", label: "5th 1", priority: 5 },
|
||||
"FL": { class: "points-fl", label: "FL +1", priority: 6 }
|
||||
};
|
||||
|
||||
// Main data object - Adjusted initial data to match new point values
|
||||
let appData = {
|
||||
drivers: [
|
||||
"Filip Makarun", "Karlo Štefanekrucker", "Fran Jurmanović", "Borna Bevanda", "Matej Jugović",
|
||||
"Lovo Bravić", "Dominik Dejanović", "Josip Kompanović", "Tin Vidmar", "Ivan Ivezić",
|
||||
"Tomislav Glavaš"
|
||||
],
|
||||
tracks: [
|
||||
{ name: "Hungaroring", results: [2, 1, 1, 2, 0, 5, 0, 7, 0, 0, 0], fastestLap: "Tomislav Glavaš", fastestLapInitials: "T.G." },
|
||||
{ name: "Watkins Glen", results: [5, 2, 2, 10, 5, 0, 0, 0, 0, 0, 0], fastestLap: "", fastestLapInitials: "" },
|
||||
{ name: "Paul Ricard", results: [1, 0, 0, 10, 7, 1, 0, 0, 0, 0, 0], fastestLap: "Borna Bevanda", fastestLapInitials: "B.B." },
|
||||
{ name: "Silverstone", results: [0, 0, 2, 2, 0, 1, 0, 10, 2, 0, 0], fastestLap: "Matej Jugović", fastestLapInitials: "M.J." },
|
||||
{ name: "Zandvoort", results: [0, 0, 0, 2, 1, 0, 0, 0, 0, 0, 0], fastestLap: "", fastestLapInitials: "" },
|
||||
{ name: "Brands Hatch", results: [0, 2, 2, 10, 1, 1, 0, 0, 0, 0, 0], fastestLap: "Tin Vidmar", fastestLapInitials: "T.V." },
|
||||
{ name: "Circuit Ricardo Tormo (Valencia)", results: [0, 0, 0, 5, 10, 0, 0, 7, 0, 0, 0], fastestLap: "", fastestLapInitials: "" },
|
||||
{ name: "Mount Panorama Circuit", results: [0, 0, 0, 10, 5, 1, 0, 0, 0, 0, 0], fastestLap: "Borna Bevanda", fastestLapInitials: "B.B." },
|
||||
{ name: "Autodromo Internazionale Enzo e Dino Ferrari - Imola", results: [2, 2, 1, 10, 0, 5, 0, 7, 0, 0, 0], fastestLap: "Borna Bevanda", fastestLapInitials: "B.B." },
|
||||
{ name: "Barcelona", results: [5, 0, 0, 10, 2, 0, 0, 7, 0, 0, 0], fastestLap: "", fastestLapInitials: "" },
|
||||
{ name: "Indianapolis", results: [2, 2, 5, 0, 10, 0, 0, 0, 0, 0, 0], fastestLap: "Matej Jugović", fastestLapInitials: "M.J." },
|
||||
{ name: "WeatherTech Raceway Laguna Seca", results: [0, 5, 1, 10, 0, 0, 0, 2, 0, 0, 0], fastestLap: "", fastestLapInitials: "" },
|
||||
{ name: "Zolder", results: [5, 5, 5, 5, 0, 0, 0, 0, 10, 0, 0], fastestLap: "Tin Vidmar", fastestLapInitials: "T.V." },
|
||||
{ name: "Suzuka Circuit", results: [1, 0, 0, 10, 0, 0, 0, 2, 0, 0, 0], fastestLap: "", fastestLapInitials: "" },
|
||||
{ name: "Snetterton", results: [2, 1, 0, 5, 10, 0, 0, 0, 0, 0, 0], fastestLap: "", fastestLapInitials: "" },
|
||||
{ name: "Spa-Francochamps", results: [0, 0, 5, 2, 10, 0, 0, 0, 0, 0, 0], fastestLap: "", fastestLapInitials: "" },
|
||||
{ name: "Circuit of the Americas (COTA)", results: [0, 0, 0, 10, 0, 7, 7, 0, 0, 2, 1], fastestLap: "Matej Jugović", fastestLapInitials: "M.J." },
|
||||
{ name: "Donington Park", results: [5, 1, 1, 0, 10, 7, 0, 0, 0, 2, 2], fastestLap: "Matej Jugović", fastestLapInitials: "M.J." },
|
||||
{ name: "Monza", results: [1, 0, 0, 5, 10, 0, 0, 0, 0, 0, 0], fastestLap: "Matej Jugović", fastestLapInitials: "M.J." },
|
||||
{ name: "Misano", results: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], fastestLap: "", fastestLapInitials: "" },
|
||||
{ name: "Kyalami Grand Prix Circuit", results: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], fastestLap: "", fastestLapInitials: "" },
|
||||
{ name: "Oulton Park", results: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], fastestLap: "", fastestLapInitials: "" },
|
||||
{ name: "Red Bull Ring", results: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], fastestLap: "", fastestLapInitials: "" },
|
||||
{ name: "Nürburgring", results: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], fastestLap: "", fastestLapInitials: "" }
|
||||
]
|
||||
};
|
||||
|
||||
// --- CORE FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Initializes the application, renders all components.
|
||||
*/
|
||||
function initializeApp() {
|
||||
renderRaceTable();
|
||||
const standingsData = calculateStandingsData();
|
||||
renderStandings(standingsData);
|
||||
renderCombinedChart(standingsData);
|
||||
renderPointsKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the contenteditable state of the table.
|
||||
*/
|
||||
function toggleEditMode() {
|
||||
isEditable = !isEditable;
|
||||
const status = document.getElementById('edit-mode-status');
|
||||
status.textContent = isEditable ? 'Edit Mode is ON. Click any cell to edit.' : '';
|
||||
|
||||
// Re-render table to apply 'contenteditable' attribute
|
||||
renderRaceTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a point value, handling "DNS", "FIA", etc.
|
||||
* @param {string|number} value - The value from the results table.
|
||||
* @returns {number} - The numeric point value.
|
||||
*/
|
||||
function parsePoints(value) {
|
||||
const num = parseInt(value, 10);
|
||||
return isNaN(num) ? 0 : num;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates total points and race history for all drivers.
|
||||
* @returns {Array} - Sorted array of driver objects with totals and history.
|
||||
*/
|
||||
function calculateStandingsData() {
|
||||
const standings = appData.drivers.map((driverName, driverIndex) => {
|
||||
let totalPoints = 0;
|
||||
let cumulativePoints = 0;
|
||||
const raceHistory = [];
|
||||
|
||||
appData.tracks.forEach((track, trackIndex) => {
|
||||
// Get points from main result
|
||||
let racePoints = parsePoints(track.results[driverIndex]);
|
||||
|
||||
// Add fastest lap point if applicable
|
||||
if (track.fastestLap === driverName) {
|
||||
racePoints += 1;
|
||||
}
|
||||
|
||||
totalPoints += racePoints;
|
||||
|
||||
// For the chart
|
||||
cumulativePoints += racePoints;
|
||||
raceHistory.push({
|
||||
trackName: track.name,
|
||||
points: racePoints,
|
||||
cumulativePoints: cumulativePoints
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
name: driverName,
|
||||
totalPoints: totalPoints,
|
||||
raceHistory: raceHistory
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by total points, descending
|
||||
standings.sort((a, b) => b.totalPoints - a.totalPoints);
|
||||
return standings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches event listeners to the race table body to handle live edits.
|
||||
*/
|
||||
function addEditListeners() {
|
||||
const table = document.getElementById('race-results-table');
|
||||
const head = document.getElementById('race-table-head'); // Get the head element
|
||||
|
||||
table.addEventListener('blur', (e) => {
|
||||
if (!isEditable) return;
|
||||
|
||||
const target = e.target;
|
||||
|
||||
// Handle editing points/text
|
||||
if (target.classList.contains('point-cell')) {
|
||||
const trackIndex = target.dataset.trackIndex;
|
||||
const driverIndex = target.dataset.driverIndex;
|
||||
const newValue = target.textContent.trim();
|
||||
|
||||
if (appData.tracks[trackIndex].results[driverIndex] != newValue) {
|
||||
appData.tracks[trackIndex].results[driverIndex] = newValue;
|
||||
initializeApp(); // Re-calculate and re-render everything
|
||||
}
|
||||
}
|
||||
|
||||
// Handle editing track names
|
||||
if (target.classList.contains('track-name')) {
|
||||
const trackIndex = target.dataset.trackIndex;
|
||||
const newValue = target.textContent.trim();
|
||||
|
||||
if (appData.tracks[trackIndex].name !== newValue) {
|
||||
appData.tracks[trackIndex].name = newValue;
|
||||
initializeApp(); // Re-calculate and re-render everything
|
||||
}
|
||||
}
|
||||
|
||||
// Handle editing FL Initials
|
||||
if (target.classList.contains('fl-initials-cell')) {
|
||||
const trackIndex = target.dataset.trackIndex;
|
||||
const newValue = target.textContent.trim();
|
||||
|
||||
if (appData.tracks[trackIndex].fastestLapInitials !== newValue) {
|
||||
appData.tracks[trackIndex].fastestLapInitials = newValue;
|
||||
// No initializeApp() call needed, as this is just a display-only text
|
||||
}
|
||||
}
|
||||
|
||||
// Handle editing driver names
|
||||
if (target.classList.contains('driver-name')) {
|
||||
const driverIndex = target.dataset.driverIndex;
|
||||
const newValue = target.textContent.trim();
|
||||
|
||||
if (appData.drivers[driverIndex] !== newValue) {
|
||||
appData.drivers[driverIndex] = newValue;
|
||||
initializeApp(); // Re-calculate and re-render everything
|
||||
}
|
||||
}
|
||||
}, true); // Use capture phase to ensure 'blur' is caught
|
||||
|
||||
// Add click listener to the table head for delegation
|
||||
head.addEventListener('click', (e) => {
|
||||
// Check if the click was on the add-driver-btn or its icon
|
||||
if (e.target.id === 'add-driver-btn' || e.target.closest('#add-driver-btn')) {
|
||||
addNewDriver();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new driver to the appData and re-initializes the app.
|
||||
*/
|
||||
function addNewDriver() {
|
||||
if (!isEditable) return; // Safety check
|
||||
|
||||
const newDriverName = `New Driver ${appData.drivers.length + 1}`;
|
||||
|
||||
// 1. Add driver to main list
|
||||
appData.drivers.push(newDriverName);
|
||||
|
||||
// 2. Add a default result for this driver to every track
|
||||
appData.tracks.forEach(track => {
|
||||
track.results.push(0); // Add a 0 (or "DNS")
|
||||
});
|
||||
|
||||
// 3. Re-render everything
|
||||
initializeApp();
|
||||
}
|
||||
|
||||
|
||||
// --- RENDER FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Renders the main "Race Results" table.
|
||||
*/
|
||||
function renderRaceTable() {
|
||||
const head = document.getElementById('race-table-head');
|
||||
const body = document.getElementById('race-table-body');
|
||||
|
||||
// 1. Render Header
|
||||
let headHTML = '<tr><th scope="col" class="py-3 px-4 sticky left-0 bg-gray-900/50 min-w-[200px]">Track</th>';
|
||||
headHTML += '<th scope="col" class="py-3 px-4 min-w-[100px]">FL</th>'; // New FL Header
|
||||
|
||||
appData.drivers.forEach((driver, index) => {
|
||||
headHTML += `<th scope="col" contenteditable="${isEditable}" data-driver-index="${index}" class="driver-name py-3 px-4 min-w-[150px]">${driver}</th>`;
|
||||
});
|
||||
|
||||
// Add the "Add Driver" button if in edit mode
|
||||
if (isEditable) {
|
||||
headHTML += `
|
||||
<th scope="col" class="py-3 px-4 align-middle">
|
||||
<button id="add-driver-btn" title="Add New Driver" class="w-8 h-8 flex items-center justify-center bg-green-600 text-white rounded-full hover:bg-green-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path></svg>
|
||||
</button>
|
||||
</th>
|
||||
`;
|
||||
}
|
||||
|
||||
headHTML += '</tr>';
|
||||
head.innerHTML = headHTML;
|
||||
|
||||
// 2. Render Body
|
||||
let bodyHTML = '';
|
||||
appData.tracks.forEach((track, trackIndex) => {
|
||||
bodyHTML += '<tr>';
|
||||
// Track Name (Row Header)
|
||||
bodyHTML += `<td contenteditable="${isEditable}" data-track-index="${trackIndex}" class="track-name font-semibold py-3 px-4 text-left sticky left-0 bg-gray-800/70 whitespace-nowrap min-w-[200px]">${track.name}</td>`;
|
||||
|
||||
// New: Fastest Lap Initials
|
||||
bodyHTML += `<td contenteditable="${isEditable}" data-track-index="${trackIndex}" class="fl-initials-cell py-3 px-4 font-mono min-w-[100px]">${track.fastestLapInitials || ''}</td>`;
|
||||
|
||||
// Results for each driver
|
||||
appData.drivers.forEach((driver, driverIndex) => {
|
||||
const result = track.results[driverIndex];
|
||||
// IMPORTANT: We need to map the result (e.g., 10 for P1) to a key in pointsKey (e.g., "10")
|
||||
// If the result isn't a direct number, we'll try to match it as a string (like "FL")
|
||||
const pointsKeyToUse = typeof result === 'number' ? result.toString() : result;
|
||||
|
||||
const style = pointsKey[pointsKeyToUse] || {};
|
||||
let cssClass = style.class || '';
|
||||
let displayText = result;
|
||||
|
||||
// Special handling for Fastest Lap
|
||||
if (track.fastestLap === driver) {
|
||||
// If the actual result is not "FL", we still apply the FL point color and append "+1"
|
||||
if (result !== "FL") {
|
||||
displayText = `${result} (+1)`;
|
||||
}
|
||||
cssClass = pointsKey["FL"].class; // Ensure FL color is used
|
||||
}
|
||||
|
||||
bodyHTML += `<td contenteditable="${isEditable}" data-track-index="${trackIndex}" data-driver-index="${driverIndex}" class="point-cell py-3 px-4 font-mono ${cssClass}">${displayText}</td>`;
|
||||
});
|
||||
bodyHTML += '</tr>';
|
||||
});
|
||||
body.innerHTML = bodyHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the "Driver Standings" table.
|
||||
* @param {Array} standingsData - The data from calculateStandingsData().
|
||||
*/
|
||||
function renderStandings(standingsData) {
|
||||
const body = document.getElementById('standings-body');
|
||||
let bodyHTML = '';
|
||||
|
||||
standingsData.forEach((driver, index) => {
|
||||
const pos = index + 1;
|
||||
let podiumClass = '';
|
||||
if (pos === 1) podiumClass = 'podium-1';
|
||||
if (pos === 2) podiumClass = 'podium-2';
|
||||
if (pos === 3) podiumClass = 'podium-3';
|
||||
|
||||
bodyHTML += `
|
||||
<tr class="border-b border-gray-700 ${podiumClass}">
|
||||
<td class="px-3 py-3 font-semibold">${pos}</td>
|
||||
<td class="px-3 py-3">${driver.name}</td>
|
||||
<td class="px-3 py-3 font-bold">${driver.totalPoints}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
body.innerHTML = bodyHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the "Points Key" table from the pointsKey object.
|
||||
*/
|
||||
function renderPointsKey() {
|
||||
const body = document.getElementById('points-key-body');
|
||||
|
||||
// Sort keys by priority (the higher the priority, the lower it appears in the list)
|
||||
const sortedKeys = Object.values(pointsKey)
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
let bodyHTML = '';
|
||||
|
||||
sortedKeys.forEach((style) => {
|
||||
bodyHTML += `<tr><td class="px-3 py-2 ${style.class}">${style.label}</td></tr>`;
|
||||
});
|
||||
body.innerHTML = bodyHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single combined line chart for all drivers.
|
||||
* @param {Array} standingsData - The data from calculateStandingsData().
|
||||
*/
|
||||
function renderCombinedChart(standingsData) {
|
||||
const container = document.getElementById('combined-chart-container');
|
||||
if (!container) return;
|
||||
container.innerHTML = ''; // Clear existing chart
|
||||
|
||||
// 1. Setup
|
||||
// Make the chart taller and provide more bottom margin for legend
|
||||
const margin = { top: 40, right: 20, bottom: 120, left: 40 };
|
||||
const containerWidth = container.getBoundingClientRect().width;
|
||||
|
||||
if (containerWidth < 50) return; // Don't render if container is not visible
|
||||
|
||||
const width = containerWidth - margin.left - margin.right;
|
||||
const height = 500 - margin.top - margin.bottom; // Taller chart
|
||||
|
||||
const trackNames = appData.tracks.map(t => t.name);
|
||||
|
||||
// 2. Create SVG
|
||||
const svg = d3.select(container)
|
||||
.append("svg")
|
||||
.attr("width", "100%")
|
||||
.attr("height", "100%")
|
||||
.attr("viewBox", `0 0 ${width + margin.left + margin.right} ${height + margin.top + margin.bottom}`)
|
||||
.append("g")
|
||||
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
||||
|
||||
// 3. Scales
|
||||
// Color scale
|
||||
const colorScale = d3.scaleOrdinal(d3.schemeCategory10)
|
||||
.domain(standingsData.map(d => d.name));
|
||||
|
||||
// X Scale
|
||||
const xScale = d3.scalePoint()
|
||||
.domain(trackNames)
|
||||
.range([0, width])
|
||||
.padding(0.5);
|
||||
|
||||
// Y Scale
|
||||
const maxY = d3.max(standingsData, driver => driver.totalPoints);
|
||||
const yScale = d3.scaleLinear()
|
||||
.domain([0, maxY > 0 ? maxY : 10]) // Ensure domain is at least 10
|
||||
.range([height, 0])
|
||||
.nice();
|
||||
|
||||
// 4. Axes
|
||||
const xAxis = d3.axisBottom(xScale);
|
||||
const yAxis = d3.axisLeft(yScale);
|
||||
|
||||
// Y-Axis Grid
|
||||
svg.append("g")
|
||||
.attr("class", "chart-grid")
|
||||
.call(d3.axisLeft(yScale)
|
||||
.ticks(5)
|
||||
.tickSize(-width)
|
||||
.tickFormat("")
|
||||
);
|
||||
|
||||
// X-Axis
|
||||
svg.append("g")
|
||||
.attr("class", "chart-axis")
|
||||
.attr("transform", `translate(0, ${height})`)
|
||||
.call(xAxis)
|
||||
.selectAll("text")
|
||||
.style("text-anchor", "end")
|
||||
.attr("dx", "-.8em")
|
||||
.attr("dy", ".15em")
|
||||
.attr("transform", "rotate(-45)");
|
||||
|
||||
// Y-Axis
|
||||
svg.append("g")
|
||||
.attr("class", "chart-axis")
|
||||
.call(yAxis);
|
||||
|
||||
// 5. Line Generator
|
||||
const line = d3.line()
|
||||
.x(d => xScale(d.trackName))
|
||||
.y(d => yScale(d.cumulativePoints))
|
||||
.curve(d3.curveMonotoneX); // Smooth curve
|
||||
|
||||
// 6. Draw Lines & Dots (one group per driver)
|
||||
const driverGroups = svg.selectAll('.driver-group')
|
||||
.data(standingsData)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'driver-group');
|
||||
|
||||
// Draw the line
|
||||
driverGroups.append("path")
|
||||
.attr("class", "chart-line")
|
||||
.style("stroke", d => colorScale(d.name))
|
||||
.attr("d", d => line(d.raceHistory));
|
||||
|
||||
// Draw the dots
|
||||
driverGroups.selectAll(".chart-dot")
|
||||
.data(d => d.raceHistory) // Use the nested raceHistory
|
||||
.enter().append("circle")
|
||||
.attr("class", "chart-dot")
|
||||
.attr("cx", d => xScale(d.trackName))
|
||||
.attr("cy", d => yScale(d.cumulativePoints))
|
||||
.attr("r", 3)
|
||||
.style("fill", (d, i, nodes) => {
|
||||
// Get the parent <g>'s data (which is the driver object)
|
||||
return colorScale(d3.select(nodes[i].parentNode).datum().name);
|
||||
});
|
||||
|
||||
// 7. Title
|
||||
svg.append("text")
|
||||
.attr("class", "chart-title")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 0 - (margin.top / 2))
|
||||
.text("Cumulative Points per Race");
|
||||
|
||||
// 8. Legend
|
||||
const legend = svg.append('g')
|
||||
.attr('class', 'chart-legend')
|
||||
.attr('transform', `translate(0, ${height + margin.bottom - 40})`); // Position legend below x-axis
|
||||
|
||||
const legendItem = legend.selectAll('.legend-item')
|
||||
.data(standingsData)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'legend-item')
|
||||
.attr('transform', (d, i) => {
|
||||
// Arrange legend in 3 columns
|
||||
const colWidth = (width / 3);
|
||||
const xPos = (i % 3) * colWidth;
|
||||
const yPos = Math.floor(i / 3) * 20;
|
||||
return `translate(${xPos}, ${yPos})`;
|
||||
});
|
||||
|
||||
legendItem.append('rect')
|
||||
.attr('width', 12)
|
||||
.attr('height', 12)
|
||||
.style('fill', d => colorScale(d.name));
|
||||
|
||||
legendItem.append('text')
|
||||
.attr('x', 16)
|
||||
.attr('y', 10)
|
||||
.style('fill', '#d1d5db')
|
||||
.style('font-size', '12px')
|
||||
.text(d => d.name);
|
||||
}
|
||||
|
||||
|
||||
// --- INITIALIZATION ---
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Attach button click listener
|
||||
document.getElementById('edit-mode-toggle').addEventListener('click', toggleEditMode);
|
||||
|
||||
// Attach table edit listeners
|
||||
addEditListeners();
|
||||
|
||||
// Initial render
|
||||
initializeApp();
|
||||
});
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user