mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
396 lines
14 KiB
HTML
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>
|