Port to Next.js with static export for Cloudflare Pages
- Create Next.js app with TypeScript, Tailwind, and Postcss - Extract API logic, types, and utilities to lib/ - Build React components for Race Results, Driver Standings, Points Key, and D3 Chart - Use environment variables for API credentials (NEXT_PUBLIC_*) - Static export (output: 'export') for Cloudflare Pages deployment - D3 chart rendered client-side with dynamic import - Update deploy workflow to build and deploy ./out directory - Remove original public/index.html
This commit is contained in:
3
.env.local.example
Normal file
3
.env.local.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
NEXT_PUBLIC_API_BASE=https://acc-api.jurmanovic.com/v1/api
|
||||||
|
NEXT_PUBLIC_SERVER_ID=2ea19460-f3b6-47ae-a667-c26876425268
|
||||||
|
NEXT_PUBLIC_ACCESS_TOKEN=EB41048A021F7A7707ED4E5EF37052C401F9ACD5DCDBC94BF16D0456FAAF111A
|
||||||
@@ -13,9 +13,15 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
- name: Publish to Cloudflare
|
- name: Publish to Cloudflare
|
||||||
uses: cloudflare/wrangler-action@v3
|
uses: cloudflare/wrangler-action@v3
|
||||||
with:
|
with:
|
||||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
command: pages deploy ./public --project-name=grid-gladiators-leaderboard
|
command: pages deploy ./out --project-name=grid-gladiators-leaderboard
|
||||||
|
|||||||
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
6
next-env.d.ts
vendored
Normal file
6
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
11
next.config.ts
Normal file
11
next.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "export",
|
||||||
|
trailingSlash: true,
|
||||||
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
2844
package-lock.json
generated
Normal file
2844
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "grid-gladiators-results",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "^15",
|
||||||
|
"react": "^19",
|
||||||
|
"react-dom": "^19",
|
||||||
|
"d3": "^7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/d3": "^7",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"autoprefixer": "^10",
|
||||||
|
"postcss": "^8",
|
||||||
|
"tailwindcss": "^3",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
postcss.config.mjs
Normal file
9
postcss.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -1,644 +0,0 @@
|
|||||||
<!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>
|
|
||||||
150
src/app/components/CumulativeChart.tsx
Normal file
150
src/app/components/CumulativeChart.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { DriverStanding, Track } from '@/lib/types';
|
||||||
|
|
||||||
|
interface CumulativeChartProps {
|
||||||
|
standingsData: DriverStanding[];
|
||||||
|
tracks: Track[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CumulativeChart({ standingsData, tracks }: CumulativeChartProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const renderChart = async () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const d3 = await import('d3');
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
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 = 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<any>()
|
||||||
|
.x(d => xScale(d.trackName) ?? 0)
|
||||||
|
.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) as string)
|
||||||
|
.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) ?? 0)
|
||||||
|
.attr("cy", d => yScale(d.cumulativePoints))
|
||||||
|
.attr("r", 3)
|
||||||
|
.style("fill", (_d, i, nodes) => {
|
||||||
|
const parentNode = nodes[i].parentNode as any;
|
||||||
|
return colorScale((d3.select(parentNode).datum() as any).name) as string;
|
||||||
|
});
|
||||||
|
|
||||||
|
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) as string);
|
||||||
|
|
||||||
|
legendItem.append('text')
|
||||||
|
.attr('x', 16)
|
||||||
|
.attr('y', 10)
|
||||||
|
.style('fill', '#d1d5db')
|
||||||
|
.style('font-size', '12px')
|
||||||
|
.text(d => d.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
renderChart();
|
||||||
|
}, [standingsData, tracks]);
|
||||||
|
|
||||||
|
return <div ref={containerRef} className="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50" />;
|
||||||
|
}
|
||||||
44
src/app/components/DriverStandings.tsx
Normal file
44
src/app/components/DriverStandings.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { DriverStanding } from '@/lib/types';
|
||||||
|
|
||||||
|
interface DriverStandingsProps {
|
||||||
|
standingsData: DriverStanding[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DriverStandings({ standingsData }: DriverStandingsProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="border-b border-gray-600">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Pos</th>
|
||||||
|
<th className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Color</th>
|
||||||
|
<th className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Driver</th>
|
||||||
|
<th className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Initials</th>
|
||||||
|
<th className="px-3 py-2 text-left text-sm font-semibold text-gray-300">Points</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{standingsData.map((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';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={driver.originalIndex} className={`border-b border-gray-700 ${podiumClass}`}>
|
||||||
|
<td className="px-3 py-3 font-semibold">{pos}</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<div className="w-10 h-6 rounded" style={{ backgroundColor: driver.color }}></div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3">{driver.name}</td>
|
||||||
|
<td className="px-3 py-3 font-mono">{driver.initials}</td>
|
||||||
|
<td className="px-3 py-3 font-bold">{driver.totalPoints}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/app/components/PointsKey.tsx
Normal file
46
src/app/components/PointsKey.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { PointStyle } from '@/lib/types';
|
||||||
|
|
||||||
|
interface PointsKeyProps {
|
||||||
|
pointsTable: PointStyle[];
|
||||||
|
flPoints: PointStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PointsKey({ pointsTable, flPoints }: PointsKeyProps) {
|
||||||
|
const sortedPoints = [...pointsTable].sort((a, b) => (a.priority || 99) - (b.priority || 99));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800/70 p-4 rounded-2xl shadow-2xl backdrop-blur-sm border border-gray-700/50">
|
||||||
|
<table className="w-full text-center font-bold">
|
||||||
|
<tbody>
|
||||||
|
{sortedPoints.map((style) => (
|
||||||
|
<tr key={style.points}>
|
||||||
|
<td
|
||||||
|
className="px-3 py-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: style.color,
|
||||||
|
color: style.textColor,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="points-table-label">{style.label}</span>
|
||||||
|
<span className="points-table-points ml-2">{style.points}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-2 points-fl">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexDirection: 'row' }}>
|
||||||
|
FL <span className="fl-bg-text" style={{ color: flPoints.textColor }}>(+{flPoints.points})</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/app/components/RaceResultsTable.tsx
Normal file
98
src/app/components/RaceResultsTable.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { AppData } from '@/lib/types';
|
||||||
|
import { parsePoints, findStyleByPoints } from '@/lib/leaderboard';
|
||||||
|
|
||||||
|
interface RaceResultsTableProps {
|
||||||
|
appData: AppData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RaceResultsTable({ appData }: RaceResultsTableProps) {
|
||||||
|
return (
|
||||||
|
<div id="table-container" className="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" className="w-full min-w-[1200px] border-collapse text-center">
|
||||||
|
<thead id="race-table-head">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="py-3 px-4 min-w-[200px]">Track</th>
|
||||||
|
<th scope="col" className="py-3 px-4 min-w-[100px]">FL</th>
|
||||||
|
{appData.drivers.map((driver) => (
|
||||||
|
<th key={driver.initials} scope="col" className="py-3 px-4 min-w-[170px]" style={{ textAlign: 'center' }}>
|
||||||
|
<span>{driver.name}</span>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="race-table-body" className="divide-y divide-gray-700">
|
||||||
|
{appData.tracks.map((track, trackIndex) => (
|
||||||
|
<tr key={trackIndex}>
|
||||||
|
<td className="font-semibold py-3 px-4 whitespace-nowrap min-w-[200px]">
|
||||||
|
<span>{track.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 font-mono min-w-[100px]">{track.fastestLapInitials || ''}</td>
|
||||||
|
{appData.drivers.map((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(appData, pointsNum);
|
||||||
|
|
||||||
|
let cellStyles: React.CSSProperties = {
|
||||||
|
backgroundColor: style.color,
|
||||||
|
color: style.textColor,
|
||||||
|
};
|
||||||
|
|
||||||
|
let hasFL = false;
|
||||||
|
if (track.fastestLapInitials && track.fastestLapInitials === driverInitials) {
|
||||||
|
hasFL = true;
|
||||||
|
const flColor = appData.flPoints.color;
|
||||||
|
if (pointsNum === 0) {
|
||||||
|
cellStyles = {
|
||||||
|
backgroundColor: flColor,
|
||||||
|
color: appData.flPoints.textColor,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
cellStyles = {
|
||||||
|
background: `linear-gradient(135deg, ${style.color} 50%, ${flColor} 50%)`,
|
||||||
|
color: style.textColor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasFL) {
|
||||||
|
cellStyles = {
|
||||||
|
...cellStyles,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingLeft: '0.75rem',
|
||||||
|
paddingRight: '0.75rem',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
cellStyles = {
|
||||||
|
...cellStyles,
|
||||||
|
textAlign: 'center',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td key={driverIndex} className="py-3 px-4 font-mono" style={cellStyles}>
|
||||||
|
{hasFL ? (
|
||||||
|
<>
|
||||||
|
<span style={{ flexGrow: 1, textAlign: 'left' }}>{result}</span>
|
||||||
|
<span className="fl-text-only" style={{ color: appData.flPoints.textColor }}>(+1)</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>{result}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
src/app/globals.css
Normal file
147
src/app/globals.css
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* --- Custom Styles --- */
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
background-color: #111827; /* gray-900 */
|
||||||
|
}
|
||||||
|
|
||||||
|
::-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;
|
||||||
|
}
|
||||||
23
src/app/layout.tsx
Normal file
23
src/app/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "ACC Championship Leaderboard",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className={`${inter.className} bg-gray-900 text-gray-200 p-4 md:p-8`}>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/app/page.tsx
Normal file
84
src/app/page.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { AppData } from '@/lib/types';
|
||||||
|
import { defaultAppData } from '@/lib/defaultData';
|
||||||
|
import { calculateStandingsData } from '@/lib/leaderboard';
|
||||||
|
import RaceResultsTable from './components/RaceResultsTable';
|
||||||
|
import DriverStandings from './components/DriverStandings';
|
||||||
|
import PointsKey from './components/PointsKey';
|
||||||
|
|
||||||
|
const CumulativeChart = dynamic(() => import('./components/CumulativeChart'), { ssr: false });
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE || '';
|
||||||
|
const SERVER_ID = process.env.NEXT_PUBLIC_SERVER_ID || '';
|
||||||
|
const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || '';
|
||||||
|
|
||||||
|
async function fetchLeaderboard(): Promise<AppData | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [appData, setAppData] = useState<AppData>(defaultAppData);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
const savedData = await fetchLeaderboard();
|
||||||
|
if (savedData) {
|
||||||
|
setAppData(savedData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const standingsData = useMemo(() => calculateStandingsData(appData), [appData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-7xl space-y-12">
|
||||||
|
<header className="text-center">
|
||||||
|
<h1 className="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 className="space-y-12">
|
||||||
|
<section>
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-4">Race Results</h2>
|
||||||
|
<RaceResultsTable appData={appData} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<section className="lg:col-span-2">
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-4">Driver Standings</h2>
|
||||||
|
<DriverStandings standingsData={standingsData} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="lg:col-span-1">
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-4">Points Table:</h2>
|
||||||
|
<PointsKey pointsTable={appData.pointsTable} flPoints={appData.flPoints} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-6 text-center">Cumulative Points per Race</h2>
|
||||||
|
<CumulativeChart standingsData={standingsData} tracks={appData.tracks} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/lib/defaultData.ts
Normal file
56
src/lib/defaultData.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { AppData } from './types';
|
||||||
|
|
||||||
|
const defaultColors = [
|
||||||
|
"#1f77b4", "#ff7f0e", "#2ca02c", "#81c784", "#a7d9ef", "#8c564b",
|
||||||
|
"#e377c2", "#7f7f0f", "#bcbd22", "#17becf", "#aec7e8"
|
||||||
|
];
|
||||||
|
|
||||||
|
export const defaultAppData: AppData = {
|
||||||
|
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: [null, 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: "" }
|
||||||
|
]
|
||||||
|
};
|
||||||
56
src/lib/leaderboard.ts
Normal file
56
src/lib/leaderboard.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { AppData, PointStyle, DriverStanding, RaceHistoryEntry } from './types';
|
||||||
|
|
||||||
|
export function parsePoints(value: any): number {
|
||||||
|
const num = parseInt(value, 10);
|
||||||
|
return isNaN(num) ? 0 : num;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findStyleByPoints(appData: AppData, points: number): PointStyle {
|
||||||
|
return appData.pointsTable.find(p => p.points === points) || {
|
||||||
|
points: 0,
|
||||||
|
label: '???',
|
||||||
|
color: '#374151',
|
||||||
|
textColor: '#e5e7eb',
|
||||||
|
priority: 99
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateStandingsData(appData: AppData): DriverStanding[] {
|
||||||
|
const standings = appData.drivers.map((driver, driverIndex) => {
|
||||||
|
let totalPoints = 0;
|
||||||
|
let cumulativePoints = 0;
|
||||||
|
const raceHistory: RaceHistoryEntry[] = [];
|
||||||
|
const driverInitials = driver.initials;
|
||||||
|
|
||||||
|
appData.tracks.forEach((track) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
41
src/lib/types.ts
Normal file
41
src/lib/types.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export interface Driver {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
initials: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PointStyle {
|
||||||
|
points: number;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
textColor: string;
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Track {
|
||||||
|
name: string;
|
||||||
|
results: (number | string | null | undefined)[];
|
||||||
|
fastestLapInitials: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RaceHistoryEntry {
|
||||||
|
trackName: string;
|
||||||
|
points: number;
|
||||||
|
cumulativePoints: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriverStanding {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
initials: string;
|
||||||
|
totalPoints: number;
|
||||||
|
raceHistory: RaceHistoryEntry[];
|
||||||
|
originalIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppData {
|
||||||
|
drivers: Driver[];
|
||||||
|
pointsTable: PointStyle[];
|
||||||
|
flPoints: PointStyle;
|
||||||
|
tracks: Track[];
|
||||||
|
}
|
||||||
14
tailwind.config.ts
Normal file
14
tailwind.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
export default config
|
||||||
52
tsconfig.json
Normal file
52
tsconfig.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": [
|
||||||
|
"ES2020",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowJs": true,
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user