Files
grid-gladiators-results/public/index.html
Fran Jurmanović 04552e1eee
All checks were successful
Deploy to Cloudflare Pages / deploy (push) Successful in 3m30s
update to public endpoint
2026-03-01 13:53:16 +01:00

645 lines
25 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 */
.points-fl { background-color: #8b5cf6; color: #000000; } /* violet-500 with black text */
.points-new {
background-color: #374151; /* gray-700 */
color: #e5e7eb; /* gray-200 */
}
/* 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 */
}
/* 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 {}
/* 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;
}
</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">
Grid Gladiators League Leaderboard
</h1>
</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>
</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">
// --- API CONFIG ---
const API_BASE = 'https://acc-api.jurmanovic.com/v1/api';
const SERVER_ID = '2ea19460-f3b6-47ae-a667-c26876425268';
const ACCESS_TOKEN = 'EB41048A021F7A7707ED4E5EF37052C401F9ACD5DCDBC94BF16D0456FAAF111A'
const defaultColors = [
"#1f77b4", "#ff7f0e", "#2ca02c", "#81c784", "#a7d9ef", "#8c564b",
"#e377c2", "#7f7f0f", "#bcbd22", "#17becf", "#aec7e8"
];
const defaultAppData = {
drivers: [
{ name: "Filip Makarijevski", color: defaultColors[0], initials: "F.M." },
{ name: "Karlo Steinbruckner", 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." }
],
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 },
],
flPoints: { points: 1, label: "FL +1", color: "#8b5cf6", textColor: "#000000", priority: 6 },
tracks: [
{ name: "Hungaroring", results: [], fastestLapInitials: "" },
{ name: "Watkins Glen", results: [], fastestLapInitials: "" },
{ name: "Paul Ricard", results: [], fastestLapInitials: "" },
{ name: "Silverstone", results: [], fastestLapInitials: "" },
{ name: "Zandvoort", results: [], fastestLapInitials: "" },
{ name: "Brands Hatch", results: [], fastestLapInitials: "" },
{ name: "Circuit Ricardo Tormo (Valencia)", results: [], fastestLapInitials: "" },
{ name: "Mount Panorama Circuit", results: [], fastestLapInitials: "" },
{ name: "Autodromo Internazionale Enzo e Dino Ferrari - Imola", results: [], fastestLapInitials: "" },
{ name: "Barcelona", results: [, 0, 0], fastestLapInitials: "" },
{ name: "Indianapolis", results: [], fastestLapInitials: "" },
{ name: "WeatherTech Raceway Laguna Seca", results: [], fastestLapInitials: "" },
{ name: "Zolder", results: [], fastestLapInitials: "" },
{ name: "Suzuka Circuit", results: [], fastestLapInitials: "" },
{ name: "Snetterton", results: [], fastestLapInitials: "" },
{ name: "Spa-Francochamps", results: [], fastestLapInitials: "" },
{ name: "Circuit of the Americas (COTA)", results: [], fastestLapInitials: "" },
{ name: "Donington Park", results: [], fastestLapInitials: "" },
{ name: "Monza", results: [], fastestLapInitials: "" },
{ name: "Misano", results: [], fastestLapInitials: "" },
{ name: "Kyalami Grand Prix Circuit", results: [], fastestLapInitials: "" },
{ name: "Oulton Park", results: [], fastestLapInitials: "" },
{ name: "Red Bull Ring", results: [], fastestLapInitials: "" },
{ name: "Nürburgring", results: [], fastestLapInitials: "" }
]
};
let appData = null;
// --- SAVE/LOAD FUNCTIONS ---
async function loadData() {
try {
const res = await fetch(`${API_BASE}/server/${SERVER_ID}/leaderboard`, {
headers: {
"Access-Key": ACCESS_TOKEN
}
});
if (!res.ok) return null;
const data = await res.json();
// Treat empty leaderboard (no drivers) as null to fall back to defaults
if (!data || !data.drivers || data.drivers.length === 0) return null;
return data;
} catch (e) {
console.error("Failed to load leaderboard from API:", e);
return null;
}
}
// --- CORE FUNCTIONS ---
// Find styling by point value
function findStyleByPoints(points) {
return appData.pointsTable.find(p => p.points === points) || { class: 'points-new', color: '#374151', textColor: '#e5e7eb', label: '???' };
}
// Function to re-render UI from memory
function rerender() {
renderRaceTable();
const standingsData = calculateStandingsData();
renderStandings(standingsData);
renderCombinedChart(standingsData);
renderPointsKey();
}
async function initializeApp() {
const savedData = await loadData();
if (savedData) {
appData = savedData;
} else {
appData = JSON.parse(JSON.stringify(defaultAppData));
}
rerender();
}
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 result = track.results[driverIndex];
// DNS fallback: if result is undefined, null, or empty, treat as DNS
if (result === undefined || result === null || result === '') {
result = 'DNS';
}
let racePoints = parsePoints(result);
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;
}
// --- 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" class="py-3 px-4 min-w-[170px]" style="text-align: center;">
<span>${driver.name}</span>
</th>`;
});
headHTML += '</tr>';
head.innerHTML = headHTML;
let bodyHTML = '';
appData.tracks.forEach((track, trackIndex) => {
bodyHTML += '<tr>';
bodyHTML += `
<td class="font-semibold py-3 px-4 whitespace-nowrap min-w-[200px]">
<span>${track.name}</span>
</td>`;
bodyHTML += `<td class="py-3 px-4 font-mono min-w-[100px]">${track.fastestLapInitials || ''}</td>`;
appData.drivers.forEach((driver, driverIndex) => {
const driverInitials = driver.initials;
let result = track.results[driverIndex];
// DNS fallback
if (result === undefined || result === null || result === '') {
result = 'DNS';
}
const pointsNum = parsePoints(result);
const style = findStyleByPoints(pointsNum);
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;
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) {
cellStyles += `display: flex; justify-content: space-between; align-items: center; flex-direction: row; padding-left: 0.75rem; padding-right: 0.75rem;`;
cellContent = `
<span class="flex-grow" style="text-align: left;">${result}</span>
${fastestLapBadge}
`;
} else {
cellStyles += `text-align: center;`;
cellContent = `<span>${result}</span>`;
}
bodyHTML += `
<td class="py-3 px-4 font-mono" style="${cellStyles}">
${cellContent}
</td>`;
});
bodyHTML += '</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 = '';
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">
<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">${driver.initials}</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">${style.label}</span>
<span class="points-table-points">${style.points}</span>
`;
bodyHTML += `
<tr>
<td class="px-3 py-2" style="background-color: ${style.color}; color: ${style.textColor}; display: flex; justify-content: center; align-items: center; flex-direction: row;">
<div>${labelContent}</div>
</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', async () => {
await initializeApp();
}); // --- END of DOMContentLoaded listener ---
</script>
</body>
</html>