202 lines
6.8 KiB
TypeScript
202 lines
6.8 KiB
TypeScript
// components/live/LiveTrackMap.tsx
|
|
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { getTrackMapUrl, cleanTrackName, cleanTrackConfig } from '@/lib/trackUtils';
|
|
import { getTrackMapConfig, worldToMapCoords, type TrackMapConfig } from '@/lib/trackMapConfig';
|
|
|
|
interface Car {
|
|
carID: number;
|
|
driver_name: string;
|
|
car_model: string;
|
|
normalizedSplinePos: number;
|
|
position: number;
|
|
lap_time?: number;
|
|
best_lap_time?: number;
|
|
world_position?: { x: number; y: number; z: number };
|
|
}
|
|
|
|
interface LiveTrackMapProps {
|
|
track: string;
|
|
trackConfig: string;
|
|
cars: Car[];
|
|
}
|
|
|
|
export default function LiveTrackMap({ track, trackConfig, cars }: LiveTrackMapProps) {
|
|
const [imageError, setImageError] = useState(false);
|
|
const [mapConfig, setMapConfig] = useState<TrackMapConfig | null>(null);
|
|
|
|
// Static track bounds - set once and never change
|
|
const [trackBounds, setTrackBounds] = useState<{
|
|
minX: number;
|
|
maxX: number;
|
|
minZ: number;
|
|
maxZ: number;
|
|
} | null>(null);
|
|
|
|
// Add mouse position state
|
|
const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null);
|
|
|
|
// Define event handlers
|
|
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
|
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
|
setMousePos({ x, y });
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
setMousePos(null);
|
|
};
|
|
|
|
const trackMapUrl = getTrackMapUrl(track, trackConfig);
|
|
const displayTrackName = cleanTrackName(track);
|
|
const displayTrackConfig = cleanTrackConfig(trackConfig);
|
|
|
|
useEffect(() => {
|
|
getTrackMapConfig(track, trackConfig).then(config => {
|
|
setMapConfig(config);
|
|
if (config) {
|
|
const halfWidth = config.width / 2;
|
|
const halfHeight = config.height / 2;
|
|
setTrackBounds({
|
|
minX: config.xOffset - halfWidth,
|
|
maxX: config.xOffset + halfWidth,
|
|
minZ: config.zOffset - halfHeight,
|
|
maxZ: config.zOffset + halfHeight,
|
|
});
|
|
}
|
|
});
|
|
}, [track, trackConfig]);
|
|
|
|
// Convert world position to screen position
|
|
const getCarPosition = (car: Car) => {
|
|
// Use AC's formula with map.ini config
|
|
if (car.world_position && car.world_position.x !== undefined && mapConfig) {
|
|
const pos = worldToMapCoords(
|
|
car.world_position.x,
|
|
car.world_position.y,
|
|
car.world_position.z,
|
|
mapConfig
|
|
);
|
|
|
|
// Debug first car only
|
|
if (cars.indexOf(car) === 0) {
|
|
console.log(`[Map] World: (${car.world_position.x.toFixed(1)}, ${car.world_position.z.toFixed(1)}) -> Screen: (${pos.x.toFixed(1)}%, ${pos.y.toFixed(1)}%)`);
|
|
}
|
|
|
|
return pos;
|
|
}
|
|
|
|
// Fallback: distribute along diagonal based on normalizedSplinePos
|
|
const fallbackX = car.normalizedSplinePos * 100;
|
|
const fallbackY = car.normalizedSplinePos * 100;
|
|
return { x: fallbackX, y: fallbackY };
|
|
};
|
|
|
|
// Get color based on position
|
|
const getPositionColor = (position: number) => {
|
|
if (!position || position === 0) return '#6b7280'; // Gray for no position
|
|
if (position === 1) return '#ffffff'; // P1 - White
|
|
if (position === 2) return '#d1d5db'; // P2 - Light gray
|
|
if (position === 3) return '#9ca3af'; // P3 - Gray
|
|
return '#6b7280'; // Others - Dark gray
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="relative w-full aspect-square bg-black border border-white/10 overflow-hidden"
|
|
onMouseMove={handleMouseMove}
|
|
onMouseLeave={handleMouseLeave}
|
|
>
|
|
{/* Track Map Background */}
|
|
{!imageError ? (
|
|
<img
|
|
src={trackMapUrl}
|
|
alt={`${track} track map`}
|
|
className="absolute inset-0 w-full h-full object-contain opacity-60"
|
|
onError={() => setImageError(true)}
|
|
/>
|
|
) : (
|
|
<div className="absolute inset-0 flex items-center justify-center text-white/40 text-sm">
|
|
Track map not available
|
|
</div>
|
|
)}
|
|
|
|
{/* Grid overlay for reference */}
|
|
<div className="absolute inset-0 opacity-10">
|
|
<svg width="100%" height="100%" className="text-white">
|
|
<defs>
|
|
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
|
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="currentColor" strokeWidth="0.5"/>
|
|
</pattern>
|
|
</defs>
|
|
<rect width="100%" height="100%" fill="url(#grid)" />
|
|
</svg>
|
|
</div>
|
|
|
|
{/* Car Positions */}
|
|
<div className="absolute inset-0">
|
|
{cars.map((car) => {
|
|
const pos = getCarPosition(car);
|
|
const color = getPositionColor(car.position);
|
|
|
|
return (
|
|
<div
|
|
key={car.carID}
|
|
className="absolute transition-all duration-300 ease-linear"
|
|
style={{
|
|
left: `${pos.x}%`,
|
|
top: `${pos.y}%`,
|
|
transform: 'translate(-50%, -50%)',
|
|
}}
|
|
>
|
|
{/* Car dot */}
|
|
<div
|
|
className="w-4 h-4 rounded-full border-2 animate-pulse"
|
|
style={{
|
|
backgroundColor: color,
|
|
borderColor: color,
|
|
boxShadow: `0 0 10px ${color}`,
|
|
}}
|
|
/>
|
|
|
|
{/* Position number */}
|
|
{car.position > 0 && (
|
|
<div
|
|
className="absolute -top-6 left-1/2 transform -translate-x-1/2 text-xs font-bold whitespace-nowrap px-2 py-1 bg-black/80 border"
|
|
style={{ borderColor: color, color: color }}
|
|
>
|
|
P{car.position}
|
|
</div>
|
|
)}
|
|
|
|
{/* Driver name on hover */}
|
|
<div className="absolute top-6 left-1/2 transform -translate-x-1/2 opacity-0 hover:opacity-100 transition-opacity whitespace-nowrap">
|
|
<div className="px-2 py-1 bg-black/90 border border-white/20 text-xs">
|
|
{car.driver_name}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Track info overlay */}
|
|
<div className="absolute top-4 left-4 bg-black/80 border border-white/20 px-3 py-2">
|
|
<div className="text-xs font-bold tracking-wider text-white/60">TRACK</div>
|
|
<div className="text-sm font-mono">{track}</div>
|
|
{trackConfig && trackConfig !== 'default' && (
|
|
<div className="text-xs text-white/60">{trackConfig}</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Live indicator */}
|
|
<div className="absolute top-4 right-4 flex items-center space-x-2 bg-black/80 border border-white/20 px-3 py-2">
|
|
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
|
|
<span className="text-xs font-bold tracking-wider">LIVE</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|