Files
x0gp/server/web/tracks.html
T

396 lines
14 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>x0gp — Race Track Viewer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&family=Plus+Jakarta+Sans:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color: #0b0f19;
--card-bg: rgba(17, 24, 39, 0.7);
--card-border: rgba(255, 255, 255, 0.08);
--text-primary: #f3f4f6;
--text-secondary: #9ca3af;
--accent: #3b82f6;
--accent-glow: rgba(59, 130, 246, 0.15);
--gradient-accent: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Plus Jakarta Sans', sans-serif;
background-color: var(--bg-color);
color: var(--text-primary);
min-height: 100vh;
padding: 2.5rem;
background-image:
radial-gradient(circle at 10% 20%, rgba(59, 130, 246, 0.05) 0%, transparent 40%),
radial-gradient(circle at 90% 80%, rgba(37, 99, 235, 0.05) 0%, transparent 40%);
background-attachment: fixed;
}
header {
max-width: 1200px;
margin: 0 auto 3rem auto;
display: flex;
justify-content: space-between;
align-items: center;
}
h1 {
font-family: 'Outfit', sans-serif;
font-size: 2.5rem;
font-weight: 800;
letter-spacing: -0.03em;
background: linear-gradient(135deg, #ffffff 0%, #a5b4fc 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
color: var(--text-secondary);
font-size: 0.95rem;
margin-top: 0.25rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 16px;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
backdrop-filter: blur(12px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(800px circle at var(--x, 0px) var(--y, 0px), rgba(255, 255, 255, 0.06), transparent 40%);
pointer-events: none;
opacity: 0;
transition: opacity 0.5s;
}
.card:hover::before {
opacity: 1;
}
.card:hover {
transform: translateY(-4px);
border-color: rgba(59, 130, 246, 0.3);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(59, 130, 246, 0.1);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.track-name {
font-family: 'Outfit', sans-serif;
font-size: 1.35rem;
font-weight: 600;
color: #ffffff;
}
.track-id {
font-family: monospace;
font-size: 0.8rem;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.05);
padding: 0.2rem 0.5rem;
border-radius: 6px;
}
.canvas-container {
width: 100%;
height: 280px;
background-color: #0b0f19;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.03);
position: relative;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
font-size: 0.85rem;
}
.stat-item {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.03);
padding: 0.5rem 0.75rem;
border-radius: 8px;
display: flex;
justify-content: space-between;
}
.stat-label {
color: var(--text-secondary);
}
.stat-value {
color: #ffffff;
font-weight: 600;
}
.tags-container {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.tag {
font-size: 0.75rem;
padding: 0.2rem 0.6rem;
border-radius: 20px;
background: rgba(59, 130, 246, 0.1);
color: #60a5fa;
border: 1px solid rgba(59, 130, 246, 0.2);
}
.tag-active {
background: rgba(16, 185, 129, 0.1);
color: #34d399;
border: 1px solid rgba(16, 185, 129, 0.2);
}
.loader {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
width: 100%;
grid-column: 1 / -1;
flex-direction: column;
gap: 1rem;
color: var(--text-secondary);
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(59, 130, 246, 0.1);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<header>
<div>
<h1>x0gp — Race Track Catalog</h1>
<div class="subtitle">Visualizing physical tracks and their generated centerline geometry</div>
</div>
</header>
<main class="grid" id="tracks-grid">
<div class="loader">
<div class="spinner"></div>
<div>Loading tracks from server API...</div>
</div>
</main>
<script>
// Track SVG centerline rendering algorithm
function generateSvgPath(centerline, bounds, padding = 30, size = 260) {
if (!centerline || centerline.length < 2) return null;
const minX = bounds.min_x;
const maxX = bounds.max_x;
const minY = bounds.min_y;
const maxY = bounds.max_y;
const rangeX = maxX - minX || 1;
const rangeY = maxY - minY || 1;
const aspectRatio = rangeX / rangeY;
let width, height;
if (aspectRatio > 1) {
width = size;
height = size / aspectRatio;
} else {
width = size * aspectRatio;
height = size;
}
const canvasWidth = width + padding * 2;
const canvasHeight = height + padding * 2;
const pixelPoints = centerline.map((point) => ({
x: ((point.x - minX) / rangeX) * width + padding,
y: ((point.y - minY) / rangeY) * height + padding,
heading: point.heading_rad,
curvature: point.curvature
}));
let dAttribute = '';
for (let i = 0; i < pixelPoints.length; i++) {
const current = pixelPoints[i];
if (i === 0) {
dAttribute += `M ${current.x.toFixed(2)} ${current.y.toFixed(2)}`;
} else {
const prev = pixelPoints[i - 1];
const dx = current.x - prev.x;
const dy = current.y - prev.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const tension = 0.30 + (prev.curvature * 0.05);
const cpLength = distance * tension;
const cp1x = prev.x + Math.cos(prev.heading) * cpLength;
const cp1y = prev.y + Math.sin(prev.heading) * cpLength;
const cp2x = current.x - Math.cos(current.heading) * cpLength;
const cp2y = current.y - Math.sin(current.heading) * cpLength;
dAttribute += ` C ${cp1x.toFixed(2)} ${cp1y.toFixed(2)}, ${cp2x.toFixed(2)} ${cp2y.toFixed(2)}, ${current.x.toFixed(2)} ${current.y.toFixed(2)}`;
}
}
if (pixelPoints.length > 2) {
const first = pixelPoints[0];
const last = pixelPoints[pixelPoints.length - 1];
const dx = first.x - last.x;
const dy = first.y - last.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const tension = 0.3 + last.curvature * 0.05;
const cpLength = distance * tension;
const cp1x = last.x + Math.cos(last.heading) * cpLength;
const cp1y = last.y + Math.sin(last.heading) * cpLength;
const cp2x = first.x - Math.cos(first.heading) * cpLength;
const cp2y = first.y - Math.sin(first.heading) * cpLength;
dAttribute += ` C ${cp1x.toFixed(2)} ${cp1y.toFixed(2)}, ${cp2x.toFixed(2)} ${cp2y.toFixed(2)}, ${first.x.toFixed(2)} ${first.y.toFixed(2)} Z`;
}
return {
d: dAttribute,
canvasWidth,
canvasHeight
};
}
async function init() {
const grid = document.getElementById('tracks-grid');
try {
const res = await fetch('/api/tracks');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
grid.innerHTML = '';
if (!data.tracks || data.tracks.length === 0) {
grid.innerHTML = '<div class="loader">No tracks found on the server.</div>';
return;
}
data.tracks.forEach(track => {
const card = document.createElement('div');
card.className = 'card';
// Track hover mouse light effect
card.addEventListener('mousemove', e => {
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
card.style.setProperty('--x', `${x}px`);
card.style.setProperty('--y', `${y}px`);
});
// Generate SVG
const svgData = generateSvgPath(track.centerline, track.bounds);
let svgHtml = '<div style="color: var(--text-secondary); font-size: 0.85rem;">No centerline coordinates</div>';
if (svgData) {
svgHtml = `
<svg width="100%" height="100%" viewBox="0 0 ${svgData.canvasWidth} ${svgData.canvasHeight}" preserveAspectRatio="xMidYMid meet" style="display: block; max-width: 100%; max-height: 100%; height: auto; transform-origin: center;">
<path d="${svgData.d}" fill="none" stroke="#ffffff" stroke-width="20" stroke-linecap="round" stroke-linejoin="round" />
<path d="${svgData.d}" fill="none" stroke="#111827" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" />
</svg>
`;
}
const isActiveTag = track.is_active ? '<span class="tag tag-active">Active Track</span>' : '';
const bestLapStr = track.best_lap_ms > 0 ? `${(track.best_lap_ms / 1000).toFixed(3)}s` : 'None';
card.innerHTML = `
<div class="card-header">
<div>
<div class="track-name">${track.name}</div>
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-top: 0.2rem;">${track.description || 'No description'}</div>
</div>
<div class="track-id">${track.id}</div>
</div>
<div class="canvas-container">
${svgHtml}
</div>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">Length</span>
<span class="stat-value">${track.length_m.toFixed(1)} m</span>
</div>
<div class="stat-item">
<span class="stat-label">Width</span>
<span class="stat-value">${track.width_m.toFixed(1)} m</span>
</div>
<div class="stat-item">
<span class="stat-label">Lane Width</span>
<span class="stat-value">${track.lane_width_m.toFixed(1)} m</span>
</div>
<div class="stat-item">
<span class="stat-label">Best Lap</span>
<span class="stat-value">${bestLapStr}</span>
</div>
</div>
<div class="tags-container">
<span class="tag" style="background: rgba(255,255,255,0.05); color: #fff; border-color: rgba(255,255,255,0.1); text-transform: capitalize;">${track.surface}</span>
${isActiveTag}
</div>
`;
grid.appendChild(card);
});
} catch (err) {
grid.innerHTML = `<div class="loader" style="color: #ef4444;">Failed to load tracks: ${err.message}</div>`;
}
}
init();
</script>
</body>
</html>