1107 lines
46 KiB
HTML
1107 lines
46 KiB
HTML
<!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>
|
|
|
|
<script src="https://cdn.tailwindcss.com" defer></script>
|
|
<script src="https://d3js.org/d3.v7.min.js" defer></script>
|
|
|
|
<style>
|
|
/* --- Custom Styles --- */
|
|
body {
|
|
font-family: 'Inter', sans-serif;
|
|
background-color: #111827; /* gray-900 */
|
|
}
|
|
|
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
|
|
|
|
::-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);
|
|
color: #422006;
|
|
font-weight: 700;
|
|
border-left: 4px solid #f59e0b;
|
|
}
|
|
.podium-2 {
|
|
background: linear-gradient(145deg, #9ca3af, #6b7280); /* gray-400, gray-500 */
|
|
color: #1f2937;
|
|
font-weight: 600;
|
|
border-left: 4px solid #9ca3af; /* gray-400 */
|
|
}
|
|
.podium-3 {
|
|
background: linear-gradient(145deg, #f0c0a0, #e69a6a);
|
|
color: #431407;
|
|
font-weight: 600;
|
|
border-left: 4px solid #c2410c;
|
|
}
|
|
|
|
/* Points Table Key Styles - REMOVED static .points-p* classes */
|
|
|
|
/* FL row is still static */
|
|
.points-fl { background-color: #8b5cf6; color: #000000; } /* violet-500 with black text */
|
|
|
|
/* NEW: Default class for new point rows */
|
|
.points-new {
|
|
background-color: #374151; /* gray-700 */
|
|
color: #e5e7eb; /* gray-200 */
|
|
}
|
|
|
|
/* REMOVED: All static .split-p* classes */
|
|
|
|
/* Style for the (+1) badge in the POINTS TABLE */
|
|
.fl-bg-text {
|
|
background-color: #8b5cf6; /* violet-500 */
|
|
color: #000000; /* Black text */
|
|
font-weight: 700;
|
|
padding: 0.125rem 0.5rem; /* px-2 py-0.5 */
|
|
border-radius: 0.375rem; /* rounded-md */
|
|
margin-left: 0.5rem; /* ml-2 */
|
|
flex-shrink: 0;
|
|
line-height: 1.25;
|
|
}
|
|
|
|
/* Style for the (+1) text-only in the RESULTS TABLE */
|
|
.fl-text-only {
|
|
color: #000000; /* Black text */
|
|
font-weight: 700;
|
|
padding: 0.125rem 0.5rem; /* px-2 py-0.5 */
|
|
margin-left: 0.5rem; /* ml-2 */
|
|
flex-shrink: 0;
|
|
line-height: 1.25;
|
|
/* No background, no border-radius */
|
|
}
|
|
|
|
|
|
/* 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;
|
|
padding-left: 0.25rem;
|
|
padding-right: 0.25rem;
|
|
border-radius: 0.25rem;
|
|
}
|
|
[contenteditable="true"]:hover {
|
|
background-color: #374151; /* gray-700 */
|
|
}
|
|
|
|
/* Style for NATIVE color picker */
|
|
.driver-color-picker, .points-table-color-picker {
|
|
-webkit-appearance: none;
|
|
-moz-appearance: none;
|
|
appearance: none;
|
|
width: 2.5rem; /* w-10 */
|
|
height: 1.5rem; /* h-6 */
|
|
padding: 0;
|
|
border: 1px solid #4b5563; /* gray-600 */
|
|
border-radius: 0.375rem; /* rounded */
|
|
cursor: pointer;
|
|
}
|
|
.driver-color-picker::-webkit-color-swatch-wrapper,
|
|
.points-table-color-picker::-webkit-color-swatch-wrapper {
|
|
padding: 0;
|
|
border-radius: 0.375rem;
|
|
}
|
|
.driver-color-picker::-webkit-color-swatch,
|
|
.points-table-color-picker::-webkit-color-swatch {
|
|
border: none;
|
|
border-radius: 0.375rem;
|
|
}
|
|
|
|
/* NEW: Make points table color picker smaller */
|
|
.points-table-color-picker {
|
|
width: 2rem; /* w-8 */
|
|
height: 1.25rem; /* h-5 */
|
|
}
|
|
|
|
|
|
/* Sticky header for table */
|
|
#race-table-head th {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 10;
|
|
background-color: #111827;
|
|
}
|
|
|
|
/* Sticky first column */
|
|
#race-table-body td:first-child,
|
|
#race-table-head th:first-child {
|
|
position: sticky;
|
|
left: 0;
|
|
z-index: 11;
|
|
background-color: #111827;
|
|
min-width: 200px;
|
|
}
|
|
#race-table-body td:first-child {
|
|
z-index: 1;
|
|
background-color: #1f2937;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
text-align: left;
|
|
}
|
|
#race-table-head th:first-child {
|
|
text-align: left;
|
|
padding-left: 1rem;
|
|
}
|
|
|
|
/* 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;
|
|
background-color: #111827;
|
|
min-width: 100px;
|
|
font-weight: 600;
|
|
color: #a855f7;
|
|
}
|
|
#race-table-body td:nth-child(2) {
|
|
z-index: 1;
|
|
background-color: #1f2937;
|
|
}
|
|
|
|
/* Remove Track/Driver Button Style */
|
|
.remove-track-btn, .remove-driver-btn, .remove-points-row-btn {
|
|
display: none;
|
|
font-family: monospace;
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
color: #ef4444; /* red-500 */
|
|
background-color: #374151; /* gray-700 */
|
|
border-radius: 99px;
|
|
width: 20px;
|
|
height: 20px;
|
|
line-height: 20px;
|
|
text-align: center;
|
|
margin-left: 12px;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
transition: all 0.2s;
|
|
}
|
|
.remove-track-btn:hover, .remove-driver-btn:hover, .remove-points-row-btn:hover {
|
|
background-color: #ef4444; /* red-500 */
|
|
color: #ffffff;
|
|
}
|
|
td[data-is-editable="true"] .remove-track-btn,
|
|
th[data-is-editable="true"] .remove-driver-btn,
|
|
td[data-is-editable="true"] .remove-points-row-btn {
|
|
display: inline-block;
|
|
}
|
|
|
|
/* D3 Chart Styles */
|
|
.chart-axis path,
|
|
.chart-axis line {
|
|
stroke: #9ca3af;
|
|
}
|
|
.chart-axis text {
|
|
fill: #d1d5db;
|
|
font-size: 12px;
|
|
}
|
|
.chart-title {
|
|
fill: #f9fafb;
|
|
font-size: 1.25rem;
|
|
font-weight: 600;
|
|
text-anchor: middle;
|
|
}
|
|
.chart-grid line {
|
|
stroke: #374151;
|
|
stroke-opacity: 0.7;
|
|
shape-rendering: crispEdges;
|
|
}
|
|
.chart-line {
|
|
fill: none;
|
|
stroke-width: 2.5px;
|
|
}
|
|
.chart-dot {}
|
|
</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>
|
|
<div class="mt-4 space-x-4">
|
|
<button id="edit-mode-toggle" class="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>
|
|
<button id="save-btn" class="px-6 py-2 bg-green-600 text-white font-semibold rounded-lg shadow-md hover:bg-green-700 transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-opacity-50">
|
|
Save Changes
|
|
</button>
|
|
</div>
|
|
<p id="edit-mode-status" class="text-yellow-400 mt-2 h-4"></p>
|
|
</header>
|
|
|
|
<main class="space-y-12">
|
|
<section>
|
|
<h2 class="text-2xl font-bold text-white mb-4">Race Results</h2>
|
|
<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"></thead>
|
|
<tbody id="race-table-body" class="divide-y divide-gray-700"></tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
<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">Color</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">Initials</th>
|
|
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-300">Points</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="standings-body"></tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<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>
|
|
<button id="add-points-row-btn" class="w-full py-2 bg-green-600 text-white font-semibold rounded-lg shadow-md hover:bg-green-700 transition-colors mt-4 hidden">
|
|
+ Add Row
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
|
|
<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"></div>
|
|
</section>
|
|
|
|
|
|
<script type="module">
|
|
|
|
// --- APPLICATION STATE ---
|
|
let isEditable = false;
|
|
const storageKey = 'accLeaderboardData';
|
|
|
|
const defaultColors = [
|
|
"#1f77b4", "#ff7f0e", "#2ca02c", "#81c784", "#a7d9ef", "#8c564b",
|
|
"#e377c2", "#7f7f0f", "#bcbd22", "#17becf", "#aec7e8"
|
|
];
|
|
|
|
const defaultAppData = {
|
|
drivers: [
|
|
{ name: "Filip Makarun", color: defaultColors[0], initials: "F.M." },
|
|
{ name: "Karlo Štefanekrucker", color: defaultColors[1], initials: "K.S." },
|
|
{ name: "Fran Jurmanović", color: defaultColors[2], initials: "F.J." },
|
|
{ name: "Borna Bevanda", color: defaultColors[3], initials: "B.B." },
|
|
{ name: "Matej Jugović", color: defaultColors[4], initials: "M.J." },
|
|
{ name: "Lovo Bravić", color: defaultColors[5], initials: "L.B." },
|
|
{ name: "Dominik Dejanović", color: defaultColors[6], initials: "D.D." },
|
|
{ name: "Josip Kompanović", color: defaultColors[7], initials: "J.K." },
|
|
{ name: "Tin Vidmar", color: defaultColors[8], initials: "T.V." },
|
|
{ name: "Ivan Ivezić", color: defaultColors[9], initials: "I.I." },
|
|
{ name: "Tomislav Glavaš", color: defaultColors[10], initials: "T.G." }
|
|
],
|
|
// NEW: Points table is now part of appData with editable colors
|
|
pointsTable: [
|
|
{ points: 10, label: "1st", color: "#fde047", textColor: "#422006", priority: 1 },
|
|
{ points: 7, label: "2nd", color: "#9ca3af", textColor: "#1f2937", priority: 2 },
|
|
{ points: 5, label: "3rd", color: "#f0c0a0", textColor: "#431407", priority: 3 },
|
|
{ points: 2, label: "4th", color: "#81c784", textColor: "#1F2937", priority: 4 },
|
|
{ points: 1, label: "5th", color: "#a7d9ef", textColor: "#1F2937", priority: 5 },
|
|
],
|
|
// NEW: FL points are separate and non-editable, but provide color data
|
|
flPoints: { points: 1, label: "FL +1", color: "#8b5cf6", textColor: "#000000", priority: 6 },
|
|
tracks: [
|
|
{ name: "Hungaroring", results: [2, 1, 1, 2, 0, 5, 0, 7, 0, 0, 0], fastestLapInitials: "T.G." },
|
|
{ name: "Watkins Glen", results: [5, 2, 2, 10, 5, 0, 0, 0, 0, 0, 0], fastestLapInitials: "" },
|
|
{ name: "Paul Ricard", results: [1, 0, 0, 10, 7, 1, 0, 0, 0, 0, 0], fastestLapInitials: "B.B." },
|
|
{ name: "Silverstone", results: [0, 0, 2, 2, 0, 1, 0, 10, 2, 0, 0], fastestLapInitials: "M.J." },
|
|
{ name: "Zandvoort", results: [0, 0, 0, 2, 1, 0, 0, 0, 0, 0, 0], fastestLapInitials: "" },
|
|
{ name: "Brands Hatch", results: [0, 2, 2, 10, 1, 1, 0, 0, 0, 0, 0], fastestLapInitials: "T.V." },
|
|
{ name: "Circuit Ricardo Tormo (Valencia)", results: [0, 0, 0, 5, 10, 0, 0, 7, 0, 0, 0], fastestLapInitials: "" },
|
|
{ name: "Mount Panorama Circuit", results: [0, 0, 0, 10, 5, 1, 0, 0, 0, 0, 0], fastestLapInitials: "B.B." },
|
|
{ name: "Autodromo Internazionale Enzo e Dino Ferrari - Imola", results: [2, 2, 1, 10, "DNF", 5, 0, 7, 0, 0, 0], fastestLapInitials: "B.B." },
|
|
{ name: "Barcelona", results: [5, "DNS", 0, 10, 2, 0, 0, 7, 0, 0, 0], fastestLapInitials: "" },
|
|
{ name: "Indianapolis", results: [2, 2, 5, 0, 10, 0, 0, 0, 0, 0, 0], fastestLapInitials: "M.J." },
|
|
{ name: "WeatherTech Raceway Laguna Seca", results: [0, 5, 1, 10, 0, 0, 0, 2, 0, 0, 0], fastestLapInitials: "" },
|
|
{ name: "Zolder", results: [5, 5, 5, 5, 0, 0, 0, 0, 10, 0, 0], fastestLapInitials: "T.V." },
|
|
{ name: "Suzuka Circuit", results: [1, 0, 0, 10, 0, 0, 0, 2, 0, 0, 0], fastestLapInitials: "" },
|
|
{ name: "Snetterton", results: [2, 1, 0, 5, 10, 0, 0, 0, 0, 0, 0], fastestLapInitials: "" },
|
|
{ name: "Spa-Francochamps", results: [0, 0, 5, 2, 10, 0, 0, 0, 0, 0, 0], fastestLapInitials: "" },
|
|
{ name: "Circuit of the Americas (COTA)", results: [0, 0, 0, 10, 0, 7, 7, 0, 0, 2, 1], fastestLapInitials: "M.J." },
|
|
{ name: "Donington Park", results: [5, 1, 1, 0, 10, 7, 0, 0, 0, 2, 2], fastestLapInitials: "M.J." },
|
|
{ name: "Monza", results: [1, 0, 0, 5, 10, 0, 0, 0, 0, 0, 0], fastestLapInitials: "M.J." },
|
|
{ name: "Misano", results: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], fastestLapInitials: "" },
|
|
{ name: "Kyalami Grand Prix Circuit", results: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], fastestLapInitials: "" },
|
|
{ name: "Oulton Park", results: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], fastestLapInitials: "" },
|
|
{ name: "Red Bull Ring", results: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], fastestLapInitials: "" },
|
|
{ name: "Nürburgring", results: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], fastestLapInitials: "" }
|
|
]
|
|
};
|
|
|
|
let appData = null;
|
|
|
|
// --- SAVE/LOAD FUNCTIONS ---
|
|
|
|
function saveData() {
|
|
try {
|
|
const json = JSON.stringify(appData);
|
|
localStorage.setItem(storageKey, json);
|
|
} catch (e) {
|
|
console.error("Failed to save data:", e);
|
|
}
|
|
}
|
|
|
|
function loadData() {
|
|
try {
|
|
const json = localStorage.getItem(storageKey);
|
|
if (!json) return null;
|
|
const data = JSON.parse(json);
|
|
|
|
// Migration check for 'initials'
|
|
if (data.drivers && data.drivers.length > 0 && typeof data.drivers[0].initials === 'undefined') {
|
|
data.drivers.forEach((driver, index) => {
|
|
driver.initials = defaultAppData.drivers[index]?.initials || "SET";
|
|
});
|
|
}
|
|
|
|
// Migration check for points table
|
|
if (!data.pointsTable) {
|
|
data.pointsTable = JSON.parse(JSON.stringify(defaultAppData.pointsTable));
|
|
}
|
|
if (!data.flPoints) {
|
|
data.flPoints = JSON.parse(JSON.stringify(defaultAppData.flPoints));
|
|
}
|
|
|
|
// Migration check for points table colors
|
|
if (data.pointsTable && data.pointsTable.length > 0 && typeof data.pointsTable[0].color === 'undefined') {
|
|
data.pointsTable.forEach((row, index) => {
|
|
row.color = defaultAppData.pointsTable[index]?.color || "#374151";
|
|
row.textColor = defaultAppData.pointsTable[index]?.textColor || "#e5e7eb";
|
|
});
|
|
}
|
|
|
|
return data;
|
|
} catch (e) {
|
|
console.error("Failed to load or parse data:", e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// --- CORE FUNCTIONS ---
|
|
|
|
// NEW: Find styling by point value
|
|
function findStyleByPoints(points) {
|
|
return appData.pointsTable.find(p => p.points === points) || { class: 'points-new', color: '#374151', textColor: '#e5e7eb', label: '???' };
|
|
}
|
|
|
|
// NEW: Function to re-render UI from memory
|
|
function rerender() {
|
|
renderRaceTable();
|
|
const standingsData = calculateStandingsData();
|
|
renderStandings(standingsData);
|
|
renderCombinedChart(standingsData);
|
|
renderPointsKey();
|
|
}
|
|
|
|
function initializeApp() {
|
|
const savedData = loadData();
|
|
if (savedData) {
|
|
appData = savedData;
|
|
} else {
|
|
appData = JSON.parse(JSON.stringify(defaultAppData));
|
|
}
|
|
rerender();
|
|
}
|
|
|
|
function toggleEditMode() {
|
|
isEditable = !isEditable;
|
|
const status = document.getElementById('edit-mode-status');
|
|
status.textContent = isEditable ? 'Edit Mode is ON. Click any cell to edit.' : '';
|
|
|
|
document.getElementById('add-points-row-btn').classList.toggle('hidden', !isEditable);
|
|
|
|
rerender(); // Just re-render the UI
|
|
}
|
|
|
|
function parsePoints(value) {
|
|
const num = parseInt(value, 10);
|
|
return isNaN(num) ? 0 : num;
|
|
}
|
|
|
|
function calculateStandingsData() {
|
|
const standings = appData.drivers.map((driver, driverIndex) => {
|
|
let totalPoints = 0;
|
|
let cumulativePoints = 0;
|
|
const raceHistory = [];
|
|
const driverInitials = driver.initials;
|
|
|
|
appData.tracks.forEach((track, trackIndex) => {
|
|
let racePoints = parsePoints(track.results[driverIndex]);
|
|
if (track.fastestLapInitials && track.fastestLapInitials === driverInitials) {
|
|
racePoints += appData.flPoints.points || 1;
|
|
}
|
|
totalPoints += racePoints;
|
|
cumulativePoints += racePoints;
|
|
raceHistory.push({
|
|
trackName: track.name,
|
|
points: racePoints,
|
|
cumulativePoints: cumulativePoints
|
|
});
|
|
});
|
|
|
|
return {
|
|
name: driver.name,
|
|
color: driver.color,
|
|
initials: driver.initials,
|
|
totalPoints: totalPoints,
|
|
raceHistory: raceHistory,
|
|
originalIndex: driverIndex
|
|
};
|
|
});
|
|
|
|
standings.sort((a, b) => b.totalPoints - a.totalPoints);
|
|
return standings;
|
|
}
|
|
|
|
function addEditListeners() {
|
|
const table = document.getElementById('race-results-table');
|
|
const head = document.getElementById('race-table-head');
|
|
const body = document.getElementById('race-table-body');
|
|
const standingsBody = document.getElementById('standings-body');
|
|
const pointsBody = document.getElementById('points-key-body');
|
|
|
|
table.addEventListener('blur', (e) => {
|
|
if (!isEditable) return;
|
|
const target = e.target;
|
|
|
|
if (target.classList.contains('points-value')) {
|
|
const parentCell = target.closest('.point-cell');
|
|
const trackIndex = parentCell.dataset.trackIndex;
|
|
const driverIndex = parentCell.dataset.driverIndex;
|
|
let newValue = target.textContent.trim();
|
|
|
|
if (appData.tracks[trackIndex].results[driverIndex] != newValue) {
|
|
appData.tracks[trackIndex].results[driverIndex] = newValue;
|
|
rerender();
|
|
}
|
|
}
|
|
|
|
if (target.classList.contains('track-name-span')) {
|
|
const parentCell = target.parentElement;
|
|
const trackIndex = parentCell.dataset.trackIndex;
|
|
const newValue = target.textContent.trim();
|
|
|
|
if (trackIndex !== undefined && appData.tracks[trackIndex].name !== newValue) {
|
|
appData.tracks[trackIndex].name = newValue;
|
|
rerender();
|
|
}
|
|
}
|
|
|
|
if (target.classList.contains('driver-name')) {
|
|
const parentHeader = target.parentElement;
|
|
const driverIndex = parentHeader.dataset.driverIndex;
|
|
const newValue = target.textContent.trim();
|
|
|
|
if (driverIndex !== undefined && appData.drivers[driverIndex].name !== newValue) {
|
|
appData.drivers[driverIndex].name = newValue;
|
|
rerender();
|
|
}
|
|
}
|
|
|
|
if (target.classList.contains('fl-initials-cell')) {
|
|
const trackIndex = target.dataset.trackIndex;
|
|
let letters = target.textContent.replace(/[^a-zA-Z]/g, '').toUpperCase();
|
|
let formattedText = letters.split('').join('.') + (letters.length > 0 ? '.' : '');
|
|
target.textContent = formattedText;
|
|
const newValue = formattedText;
|
|
|
|
if (appData.tracks[trackIndex].fastestLapInitials !== newValue) {
|
|
appData.tracks[trackIndex].fastestLapInitials = newValue;
|
|
rerender();
|
|
}
|
|
}
|
|
}, true);
|
|
|
|
standingsBody.addEventListener('blur', (e) => {
|
|
if (!isEditable) return;
|
|
const target = e.target;
|
|
|
|
if (target.classList.contains('driver-initials')) {
|
|
const driverIndex = target.dataset.driverIndex;
|
|
const newValue = target.textContent.trim().toUpperCase();
|
|
|
|
if (driverIndex !== undefined && appData.drivers[driverIndex].initials !== newValue) {
|
|
appData.drivers[driverIndex].initials = newValue;
|
|
rerender();
|
|
}
|
|
}
|
|
}, true);
|
|
|
|
pointsBody.addEventListener('blur', (e) => {
|
|
if (!isEditable) return;
|
|
const target = e.target;
|
|
const rowIndex = target.closest('tr')?.dataset.rowIndex;
|
|
if (rowIndex === undefined) return;
|
|
|
|
const index = parseInt(rowIndex, 10);
|
|
|
|
if (target.classList.contains('points-table-label')) {
|
|
const newValue = target.textContent.trim();
|
|
if (appData.pointsTable[index].label !== newValue) {
|
|
appData.pointsTable[index].label = newValue;
|
|
rerender();
|
|
}
|
|
}
|
|
|
|
if (target.classList.contains('points-table-points')) {
|
|
const newValue = parsePoints(target.textContent);
|
|
if (appData.pointsTable[index].points !== newValue) {
|
|
appData.pointsTable[index].points = newValue;
|
|
rerender();
|
|
}
|
|
}
|
|
}, true);
|
|
|
|
// NEW: Listener for points table color pickers
|
|
pointsBody.addEventListener('input', (e) => {
|
|
if (!isEditable) return;
|
|
const target = e.target;
|
|
const rowIndex = target.closest('tr')?.dataset.rowIndex;
|
|
if (rowIndex === undefined) return;
|
|
|
|
const index = parseInt(rowIndex, 10);
|
|
|
|
if (target.classList.contains('points-table-color-picker-bg')) {
|
|
appData.pointsTable[index].color = target.value;
|
|
rerender();
|
|
}
|
|
|
|
if (target.classList.contains('points-table-color-picker-text')) {
|
|
appData.pointsTable[index].textColor = target.value;
|
|
rerender();
|
|
}
|
|
});
|
|
|
|
pointsBody.addEventListener('click', (e) => {
|
|
if (!isEditable) return;
|
|
if (e.target.classList.contains('remove-points-row-btn')) {
|
|
const rowIndex = e.target.closest('tr').dataset.rowIndex;
|
|
removePointRow(parseInt(rowIndex, 10));
|
|
}
|
|
});
|
|
|
|
document.getElementById('add-points-row-btn').addEventListener('click', () => {
|
|
if (isEditable) addNewPointRow();
|
|
});
|
|
|
|
|
|
table.addEventListener('keydown', (e) => {
|
|
if (!isEditable) return;
|
|
|
|
const target = e.target;
|
|
|
|
// Handle FL Initials
|
|
if (target.classList.contains('fl-initials-cell')) {
|
|
const key = e.key;
|
|
if (key === 'Backspace' || key === 'Delete' || key === 'Tab' || key.startsWith('Arrow')) {
|
|
return;
|
|
}
|
|
|
|
if (key.length === 1 && /[a-zA-Z]/.test(key)) {
|
|
e.preventDefault();
|
|
let letters = target.textContent.replace(/[^a-zA-Z]/g, '').toUpperCase();
|
|
let newLetters = letters + key.toUpperCase();
|
|
let formattedText = newLetters.split('').join('.') + '.';
|
|
target.textContent = formattedText;
|
|
|
|
if (target.childNodes.length > 0) {
|
|
const sel = window.getSelection();
|
|
const newRange = document.createRange();
|
|
newRange.setStart(target.childNodes[0], formattedText.length);
|
|
newRange.collapse(true);
|
|
sel.removeAllRanges();
|
|
sel.addRange(newRange);
|
|
}
|
|
} else if (key.length === 1) {
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
});
|
|
|
|
head.addEventListener('click', (e) => {
|
|
if (!isEditable) return;
|
|
if (e.target.id === 'add-driver-btn' || e.target.closest('#add-driver-btn')) {
|
|
addNewDriver();
|
|
}
|
|
if (e.target.classList.contains('remove-driver-btn')) {
|
|
const driverIndex = e.target.dataset.driverIndex;
|
|
removeDriver(driverIndex);
|
|
}
|
|
});
|
|
|
|
body.addEventListener('click', (e) => {
|
|
if (!isEditable) return;
|
|
if (e.target.classList.contains('remove-track-btn')) {
|
|
const trackIndex = e.target.dataset.trackIndex;
|
|
removeTrack(trackIndex);
|
|
}
|
|
if (e.target.id === 'add-track-btn' || e.target.closest('#add-track-btn')) {
|
|
addNewTrack();
|
|
}
|
|
});
|
|
}
|
|
|
|
function addNewDriver() {
|
|
if (!isEditable) return;
|
|
const newDriverName = `New Driver ${appData.drivers.length + 1}`;
|
|
const newDriverColor = defaultColors[appData.drivers.length % defaultColors.length];
|
|
appData.drivers.push({ name: newDriverName, color: newDriverColor, initials: "NEW" });
|
|
appData.tracks.forEach(track => {
|
|
track.results.push("DNS");
|
|
});
|
|
rerender();
|
|
}
|
|
|
|
function removeDriver(index) {
|
|
if (!isEditable || index === undefined) return;
|
|
const driverIndex = parseInt(index, 10);
|
|
if (isNaN(driverIndex)) return;
|
|
appData.drivers.splice(driverIndex, 1);
|
|
appData.tracks.forEach(track => {
|
|
if (track.results && track.results.length > driverIndex) {
|
|
track.results.splice(driverIndex, 1);
|
|
}
|
|
});
|
|
rerender();
|
|
}
|
|
|
|
function addNewTrack() {
|
|
if (!isEditable) return;
|
|
const newTrack = {
|
|
name: "New Track",
|
|
results: Array(appData.drivers.length).fill("DNS"),
|
|
fastestLapInitials: ""
|
|
};
|
|
appData.tracks.push(newTrack);
|
|
rerender();
|
|
}
|
|
|
|
function removeTrack(index) {
|
|
if (!isEditable || index === undefined) return;
|
|
appData.tracks.splice(index, 1);
|
|
rerender();
|
|
}
|
|
|
|
function addNewPointRow() {
|
|
if (!isEditable) return;
|
|
appData.pointsTable.push({
|
|
points: 0,
|
|
label: "P?",
|
|
color: "#374151", // default gray
|
|
textColor: "#e5e7eb", // default white
|
|
priority: appData.pointsTable.length + 1
|
|
});
|
|
rerender();
|
|
}
|
|
|
|
function removePointRow(index) {
|
|
if (!isEditable || index === undefined) return;
|
|
appData.pointsTable.splice(index, 1);
|
|
rerender();
|
|
}
|
|
|
|
// --- RENDER FUNCTIONS ---
|
|
|
|
function renderRaceTable() {
|
|
const head = document.getElementById('race-table-head');
|
|
const body = document.getElementById('race-table-body');
|
|
|
|
let headHTML = '<tr><th scope="col" class="py-3 px-4 min-w-[200px]">Track</th>';
|
|
headHTML += '<th scope="col" class="py-3 px-4 min-w-[100px]">FL</th>';
|
|
|
|
appData.drivers.forEach((driver, index) => {
|
|
headHTML += `
|
|
<th scope="col" data-is-editable="${isEditable}" data-driver-index="${index}" class="driver-name-header py-3 px-4 min-w-[170px]" style="text-align: center;">
|
|
<span contenteditable="${isEditable}" class="driver-name">${driver.name}</span>
|
|
<button class="remove-driver-btn" data-driver-index="${index}" title="Remove Driver">(X)</button>
|
|
</th>`;
|
|
});
|
|
|
|
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;
|
|
|
|
let bodyHTML = '';
|
|
appData.tracks.forEach((track, trackIndex) => {
|
|
bodyHTML += '<tr>';
|
|
bodyHTML += `
|
|
<td data-track-index="${trackIndex}" data-is-editable="${isEditable}" class="track-name font-semibold py-3 px-4 whitespace-nowrap min-w-[200px]" style="display: flex; justify-content: space-between; align-items: center;">
|
|
<span contenteditable="${isEditable}" class="flex-grow track-name-span">${track.name}</span>
|
|
<button class="remove-track-btn" data-track-index="${trackIndex}" title="Remove Track">(X)</button>
|
|
</td>`;
|
|
|
|
bodyHTML += `<td contenteditable="${isEditable}" data-track-index="${trackIndex}" class="fl-initials-cell py-3 px-4 font-mono min-w-[100px]">${track.fastestLapInitials || ''}</td>`;
|
|
|
|
appData.drivers.forEach((driver, driverIndex) => {
|
|
const driverInitials = driver.initials;
|
|
const result = track.results[driverIndex];
|
|
const pointsNum = parsePoints(result);
|
|
|
|
const style = findStyleByPoints(pointsNum);
|
|
// NEW: Dynamic styling
|
|
let cellStyles = `background-color: ${style.color}; color: ${style.textColor};`;
|
|
|
|
let fastestLapBadge = '';
|
|
if (track.fastestLapInitials && track.fastestLapInitials === driverInitials) {
|
|
fastestLapBadge = `<span class="fl-text-only" style="color: ${appData.flPoints.textColor};">(+1)</span>`;
|
|
|
|
const flColor = appData.flPoints.color;
|
|
// NEW: Dynamic split background
|
|
if (pointsNum === 0) {
|
|
cellStyles = `background-color: ${flColor}; color: ${appData.flPoints.textColor};`;
|
|
} else {
|
|
cellStyles = `background: linear-gradient(135deg, ${style.color} 50%, ${flColor} 50%); color: ${style.textColor};`;
|
|
}
|
|
}
|
|
|
|
let cellContent;
|
|
if (fastestLapBadge) {
|
|
// Has FL badge: use flex wrapper
|
|
cellStyles += `display: flex; justify-content: space-between; align-items: center; flex-direction: row; padding-left: 0.75rem; padding-right: 0.75rem;`;
|
|
cellContent = `
|
|
<span class="points-value flex-grow" contenteditable="${isEditable}" style="text-align: left;">${result}</span>
|
|
${fastestLapBadge}
|
|
`;
|
|
} else {
|
|
// No FL badge: center the text
|
|
cellStyles += `text-align: center;`;
|
|
cellContent = `<span class="points-value" contenteditable="${isEditable}">${result}</span>`;
|
|
}
|
|
|
|
bodyHTML += `
|
|
<td data-track-index="${trackIndex}" data-driver-index="${driverIndex}"
|
|
class="point-cell py-3 px-4 font-mono"
|
|
style="${cellStyles}">
|
|
${cellContent}
|
|
</td>`;
|
|
});
|
|
bodyHTML += '</tr>';
|
|
});
|
|
|
|
if (isEditable) {
|
|
const totalColumns = appData.drivers.length + 2 + 1;
|
|
bodyHTML += `
|
|
<tr>
|
|
<td colspan="${totalColumns}" class="py-2">
|
|
<button id="add-track-btn" class="w-full py-2 bg-green-600 text-white font-semibold rounded-lg shadow-md hover:bg-green-700 transition-colors">
|
|
+ Add New Track
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
body.innerHTML = bodyHTML;
|
|
}
|
|
|
|
function renderStandings(standingsData) {
|
|
const body = document.getElementById('standings-body');
|
|
let bodyHTML = '';
|
|
|
|
standingsData.forEach((driver, index) => {
|
|
const pos = index + 1;
|
|
let podiumClass = '';
|
|
// Use static podium classes only for top 3 in this table
|
|
if (pos === 1) podiumClass = 'podium-1';
|
|
else if (pos === 2) podiumClass = 'podium-2';
|
|
else 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">
|
|
${isEditable ?
|
|
`<input type="color"
|
|
value="${driver.color}"
|
|
data-driver-index="${driver.originalIndex}"
|
|
class="driver-color-picker">` :
|
|
`<div class="w-10 h-6 rounded" style="background-color: ${driver.color};"></div>`
|
|
}
|
|
</td>
|
|
<td class="px-3 py-3">${driver.name}</td>
|
|
<td class="px-3 py-3 font-mono">
|
|
<span contenteditable="${isEditable}"
|
|
data-driver-index="${driver.originalIndex}"
|
|
class="driver-initials"
|
|
style="text-transform: uppercase;">${driver.initials}</span>
|
|
</td>
|
|
<td class="px-3 py-3 font-bold">${driver.totalPoints}</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
body.innerHTML = bodyHTML;
|
|
}
|
|
|
|
function renderPointsKey() {
|
|
const body = document.getElementById('points-key-body');
|
|
|
|
const sortedPoints = [...appData.pointsTable].sort((a, b) => (a.priority || 99) - (b.priority || 99));
|
|
|
|
let bodyHTML = '';
|
|
sortedPoints.forEach((style, index) => {
|
|
const labelContent = `
|
|
<span class="points-table-label" contenteditable="${isEditable}">${style.label}</span>
|
|
<span class="points-table-points" contenteditable="${isEditable}">${style.points}</span>
|
|
`;
|
|
|
|
const colorPickers = isEditable ? `
|
|
<div class="flex flex-col gap-1 ml-2">
|
|
<input type="color" value="${style.color}" data-row-index="${index}" class="points-table-color-picker points-table-color-picker-bg" title="Background Color">
|
|
<input type="color" value="${style.textColor}" data-row-index="${index}" class="points-table-color-picker points-table-color-picker-text" title="Text Color">
|
|
</div>
|
|
` : '';
|
|
|
|
bodyHTML += `
|
|
<tr data-row-index="${index}">
|
|
<td class="px-3 py-2" data-is-editable="${isEditable}"
|
|
style="background-color: ${style.color}; color: ${style.textColor}; display: flex; justify-content: center; align-items: center; flex-direction: row;">
|
|
<div class="flex-grow">${labelContent}</div>
|
|
${colorPickers}
|
|
<button class="remove-points-row-btn" title="Remove Row">(X)</button>
|
|
</td>
|
|
</tr>`;
|
|
});
|
|
|
|
// Add the non-editable FL row
|
|
const flStyle = appData.flPoints;
|
|
const flLabel = `FL <span class="fl-bg-text" style="color: ${flStyle.textColor};">(+${flStyle.points})</span>`;
|
|
bodyHTML += `
|
|
<tr>
|
|
<td class="px-3 py-2 points-fl">
|
|
<div style="display: flex; justify-content: center; align-items: center; flex-direction: row;">
|
|
${flLabel}
|
|
</div>
|
|
</td>
|
|
</tr>`;
|
|
|
|
body.innerHTML = bodyHTML;
|
|
}
|
|
|
|
function renderCombinedChart(standingsData) {
|
|
const container = document.getElementById('combined-chart-container');
|
|
if (!container) return;
|
|
container.innerHTML = '';
|
|
|
|
const margin = { top: 40, right: 20, bottom: 200, left: 40 };
|
|
const containerWidth = container.getBoundingClientRect().width;
|
|
|
|
if (containerWidth < 50) return;
|
|
|
|
const width = containerWidth - margin.left - margin.right;
|
|
const height = 500 - margin.top - margin.bottom;
|
|
const trackNames = appData.tracks.map(t => t.name);
|
|
|
|
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})`);
|
|
|
|
const colorScale = d3.scaleOrdinal()
|
|
.domain(standingsData.map(d => d.name))
|
|
.range(standingsData.map(d => d.color));
|
|
|
|
const xScale = d3.scalePoint()
|
|
.domain(trackNames.length > 0 ? trackNames : ["No Tracks"])
|
|
.range([0, width])
|
|
.padding(0.5);
|
|
|
|
const maxY = d3.max(standingsData, driver =>
|
|
d3.max(driver.raceHistory, race => race.cumulativePoints)
|
|
);
|
|
|
|
const yScale = d3.scaleLinear()
|
|
.domain([0, (maxY !== undefined && maxY > 0) ? maxY : 10])
|
|
.range([height, 0])
|
|
.nice();
|
|
|
|
const xAxis = d3.axisBottom(xScale);
|
|
const yAxis = d3.axisLeft(yScale);
|
|
|
|
svg.append("g")
|
|
.attr("class", "chart-grid")
|
|
.call(d3.axisLeft(yScale).ticks(5).tickSize(-width).tickFormat(""));
|
|
|
|
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)");
|
|
|
|
svg.append("g")
|
|
.attr("class", "chart-axis")
|
|
.call(yAxis);
|
|
|
|
const line = d3.line()
|
|
.x(d => xScale(d.trackName))
|
|
.y(d => yScale(d.cumulativePoints))
|
|
.curve(d3.curveMonotoneX);
|
|
|
|
const driverGroups = svg.selectAll('.driver-group')
|
|
.data(standingsData)
|
|
.enter()
|
|
.append('g')
|
|
.attr('class', 'driver-group');
|
|
|
|
driverGroups.append("path")
|
|
.attr("class", "chart-line")
|
|
.style("stroke", d => colorScale(d.name))
|
|
.attr("d", d => line(d.raceHistory));
|
|
|
|
driverGroups.selectAll(".chart-dot")
|
|
.data(d => d.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) => {
|
|
return colorScale(d3.select(nodes[i].parentNode).datum().name);
|
|
});
|
|
|
|
svg.append("text")
|
|
.attr("class", "chart-title")
|
|
.attr("x", width / 2)
|
|
.attr("y", 0 - (margin.top / 2))
|
|
.text("Cumulative Points per Race");
|
|
|
|
const legend = svg.append('g')
|
|
.attr('class', 'chart-legend')
|
|
.attr('transform', `translate(0, ${height + 120})`);
|
|
|
|
const legendItemWidth = 150;
|
|
const numCols = Math.floor(width / legendItemWidth);
|
|
const colWidth = (numCols > 0) ? (width / numCols) : width;
|
|
const maxCols = Math.max(1, numCols);
|
|
|
|
const legendItem = legend.selectAll('.legend-item')
|
|
.data(standingsData)
|
|
.enter()
|
|
.append('g')
|
|
.attr('class', 'legend-item')
|
|
.attr('transform', (d, i) => {
|
|
const xPos = (i % maxCols) * colWidth;
|
|
const yPos = Math.floor(i / maxCols) * 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', () => {
|
|
|
|
const editModeToggle = document.getElementById('edit-mode-toggle');
|
|
const saveButton = document.getElementById('save-btn');
|
|
|
|
editModeToggle.addEventListener('click', toggleEditMode);
|
|
|
|
addEditListeners();
|
|
|
|
document.getElementById('standings-body').addEventListener('input', (e) => {
|
|
if (isEditable && e.target.classList.contains('driver-color-picker')) {
|
|
const driverIndex = e.target.dataset.driverIndex;
|
|
const newColor = e.target.value;
|
|
|
|
if (driverIndex !== undefined && appData.drivers[driverIndex]) {
|
|
appData.drivers[driverIndex].color = newColor;
|
|
rerender(); // Re-render from memory
|
|
}
|
|
}
|
|
});
|
|
|
|
// --- Save Button Logic ---
|
|
saveButton.addEventListener('click', () => {
|
|
// First, re-calculate all standings based on any in-memory changes
|
|
rerender();
|
|
|
|
// Now, save the current appData
|
|
saveData();
|
|
|
|
// Visual feedback
|
|
const originalText = saveButton.textContent;
|
|
saveButton.textContent = 'Saved!';
|
|
saveButton.classList.remove('bg-green-600', 'hover:bg-green-700');
|
|
saveButton.classList.add('bg-blue-500', 'cursor-not-allowed');
|
|
saveButton.disabled = true;
|
|
|
|
setTimeout(() => {
|
|
saveButton.textContent = originalText;
|
|
saveButton.classList.add('bg-green-600', 'hover:bg-green-700');
|
|
saveButton.classList.remove('bg-blue-500', 'cursor-not-allowed');
|
|
saveButton.disabled = false;
|
|
}, 2000);
|
|
});
|
|
|
|
initializeApp(); // This will now load data first
|
|
|
|
}); // --- END of DOMContentLoaded listener ---
|
|
|
|
</script>
|
|
</body>
|
|
</html> |