Files
grid-gladiators-results/index.html
2025-11-17 00:30:51 +01:00

709 lines
32 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>
<!-- 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>