thewildofficial
feat(sim): improve inference control flow and runway approach behavior
b209d78
Raw
History Blame Contribute Delete
21.7 kB
import React from 'react';
import { MapContainer, TileLayer, Marker, Polyline, Circle, CircleMarker, Popup, useMap, useMapEvents, Tooltip } from 'react-leaflet';
import L from 'leaflet';
import { xyToLatLon, latLonToXY, MAP_CENTER } from '../utils/geo';
import 'leaflet/dist/leaflet.css';
const planeIconCache = {};
// SVG icon to represent a plane pointing UP (0 degrees = North)
const createPlaneIcon = (heading, isSelected, scale = 1) => {
const roundedHeading = Math.round(heading);
const key = `${roundedHeading}-${isSelected}-${scale}`;
if (planeIconCache[key]) return planeIconCache[key];
const size = 24 * scale;
const icon = L.divIcon({
className: 'custom-plane-icon',
html: `
<div style="transform: rotate(${roundedHeading}deg); transition: transform 0.3s; display: flex; justify-content: center; align-items: center; width: ${size}px; height: ${size}px; background: transparent;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="${size}" height="${size}" fill="${isSelected ? '#007bff' : '#333'}">
<path d="M12 2 L14 10 L22 14 L22 16 L14 14 L13 20 L15 22 L15 24 L12 23 L9 24 L9 22 L11 20 L10 14 L2 16 L2 14 L10 10 Z"/>
</svg>
</div>
`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2]
});
planeIconCache[key] = icon;
return icon;
};
const GATE_COLORS = {
"N": "#4285F4", // Blue
"NORTH": "#4285F4",
"S": "#EA4335", // Red
"SOUTH": "#EA4335",
"E": "#34A853", // Green
"EAST": "#34A853",
"W": "#FBBC05", // Yellow
"WEST": "#FBBC05"
};
const waypointIconCache = {};
const createWaypointIcon = (label, color, scale = 1, isIAF = false, isFAF = false) => {
const key = `${label}-${color}-${scale}-${isIAF}-${isFAF}`;
if (waypointIconCache[key]) return waypointIconCache[key];
const visualSize = (isIAF || isFAF ? 42 : 36) * scale;
const hitAreaSize = visualSize * 1.2;
const borderRadius = isIAF ? '2px' : (isFAF ? '50%' : '50%');
const transform = isIAF ? 'rotate(45deg)' : 'none';
const labelTransform = isIAF ? 'rotate(-45deg)' : 'none';
// Custom styling for FAF
const fafColor = '#FF8C00'; // DarkOrange
const finalColor = isFAF ? fafColor : color;
const border = isFAF ? '3px solid #fff' : '2px solid white';
const boxShadow = isFAF ? '0 0 15px rgba(255, 140, 0, 0.8)' : '0 4px 10px rgba(0,0,0,0.5)';
const icon = L.divIcon({
className: 'custom-waypoint-icon',
html: `
<div style="
width: ${hitAreaSize}px;
height: ${hitAreaSize}px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
background: transparent;
pointer-events: auto; /* Explicitly catch events */
">
<div class="waypoint-visual-marker" style="
background: ${finalColor};
color: white;
border-radius: ${borderRadius};
width: ${visualSize}px;
height: ${visualSize}px;
display: flex;
justify-content: center;
align-items: center;
font-weight: bold;
font-size: ${Math.max(10, (isIAF || isFAF ? 12 : 13) * scale)}px;
border: ${border};
box-shadow: ${boxShadow};
transform: ${transform};
pointer-events: none;
">
<span style="transform: ${labelTransform}; display: block; pointer-events: none;">
${label}
</span>
</div>
</div>
`,
iconSize: [hitAreaSize, hitAreaSize],
iconAnchor: [hitAreaSize / 2, hitAreaSize / 2]
});
waypointIconCache[key] = icon;
return icon;
};
export default function RadarMap({
flights,
selectedFlight,
airspace,
onSelectFlight,
airports = [],
activeAirport,
activeAirportConfig,
onSelectAirport,
sendWSMessage,
draftingMode,
setDraftingMode,
airportName,
setAirportName,
runwayPoints,
setRunwayPoints,
mousePos,
setMousePos,
isRunwayBidirectional,
starDraft,
setStarDraft,
sidDraft,
setSidDraft,
activeRunways = [],
windHeading = 0,
windSpeed = 0,
setHoveredWaypoint
}) {
const [clickedInfo, setClickedInfo] = React.useState(null);
const [toastKey, setToastKey] = React.useState(0);
const [currentZoom, setCurrentZoom] = React.useState(13);
const hoverTimer = React.useRef(null);
const mapAnchor = React.useMemo(() => {
if (activeAirportConfig?.anchor) {
return {
lat: activeAirportConfig.anchor.lat,
lon: activeAirportConfig.anchor.lon
};
}
return { lat: activeAirport.lat, lon: activeAirport.lon };
}, [activeAirportConfig, activeAirport]);
function RecenterMap({ center }) {
const map = useMapEvents({});
React.useEffect(() => {
map.setView(center, map.getZoom(), { animate: false });
}, [map, center]);
return null;
}
function AutoFitOnSpawn({ flights, anchor }) {
const map = useMap();
const previousFlightCountRef = React.useRef(flights.length);
React.useEffect(() => {
const previousCount = previousFlightCountRef.current;
const currentCount = flights.length;
if (currentCount > previousCount && currentCount > 0) {
const positions = flights.map((flight) =>
xyToLatLon(flight.x, flight.y, anchor)
);
const bounds = L.latLngBounds(positions);
bounds.extend([anchor.lat, anchor.lon]);
map.fitBounds(bounds.pad(0.25), { animate: true, duration: 0.5, maxZoom: 12 });
}
previousFlightCountRef.current = currentCount;
}, [flights, anchor, map]);
return null;
}
React.useEffect(() => {
if (clickedInfo) {
const timer = setTimeout(() => setClickedInfo(null), 2000);
return () => clearTimeout(timer);
}
}, [toastKey]);
// Helper component to capture map events
function MapClickHandler() {
const map = useMapEvents({
click(e) {
const { lat, lng } = e.latlng;
if (draftingMode === 'airport') {
sendWSMessage('create_airport', {
name: airportName || `Airport ${airports.length + 1}`,
lat,
lon: lng
});
setAirportName("");
setDraftingMode(null);
return;
}
if (draftingMode === 'runway') {
const newPoints = [...runwayPoints, [lat, lng]];
if (newPoints.length === 2) {
sendWSMessage('create_runway', {
airport_code: activeAirport.airport_code,
start: newPoints[0],
end: newPoints[1],
bidirectional: isRunwayBidirectional
});
setRunwayPoints([]);
setDraftingMode(null);
} else {
setRunwayPoints(newPoints);
}
return;
}
if (draftingMode === 'waypoint') {
const { x, y } = latLonToXY(lat, lng, mapAnchor);
sendWSMessage('create_waypoint', {
airport_code: activeAirport.airport_code,
x: x,
y: y,
name: `WP_${(activeAirportConfig?.waypoints ? Object.keys(activeAirportConfig.waypoints).length : 0) + 1}`
});
return;
}
const { x, y } = latLonToXY(lat, lng, mapAnchor);
setClickedInfo({ lat, lng, x, y });
setToastKey(k => k + 1);
},
mousemove(e) {
if (draftingMode === 'runway' && runwayPoints.length === 1) {
setMousePos([e.latlng.lat, e.latlng.lng]);
} else {
if (mousePos) setMousePos(null);
}
},
zoomend() {
setCurrentZoom(map.getZoom());
}
});
return null;
}
// Calculate dynamic sizes based on zoom (base zoom 13)
const zoomScale = Math.pow(1.2, currentZoom - 13);
const airportRadius = Math.max(5, 10 * zoomScale);
const runwayWidth = Math.max(2, 12 * zoomScale);
const centerLineWidth = Math.max(1, 2 * zoomScale);
return (
<div className="map-panel">
<MapContainer center={[mapAnchor.lat, mapAnchor.lon]} zoom={11} style={{ height: '100%', width: '100%' }}>
<RecenterMap center={[mapAnchor.lat, mapAnchor.lon]} />
<AutoFitOnSpawn flights={flights} anchor={mapAnchor} />
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; OpenStreetMap contributors'
/>
{/* Radar Range Rings (10, 20, 30, 45 km) */}
{[10000, 20000, 30000, 45000].map(r => (
<Circle
key={`ring-${r}`}
center={[mapAnchor.lat, mapAnchor.lon]}
radius={r}
pathOptions={{
color: r === 45000 ? '#007bff' : '#ccc',
weight: r === 45000 ? 2 : 1,
dashArray: '10, 10',
fillOpacity: r === 45000 ? 0.02 : 0
}}
>
<Tooltip permanent direction="top" opacity={0.5}>
<span style={{ fontSize: '0.6rem', color: '#999' }}>{r / 1000} KM</span>
</Tooltip>
</Circle>
))}
{/* Cardinal Gate Markers (Now at 45km Boundary) */}
{Object.entries({
"NORTH": [0, 45],
"SOUTH": [0, -45],
"EAST": [45, 0],
"WEST": [-45, 0]
}).map(([name, xy]) => {
const pos = xyToLatLon(xy[0], xy[1], mapAnchor);
const color = GATE_COLORS[name] || '#888';
return (
<CircleMarker
key={`gate-${name}`}
center={pos}
radius={12}
pathOptions={{
color: color,
fillColor: color,
fillOpacity: 0.8,
weight: 3,
dashArray: (draftingMode === 'route' && starDraft.gate === name[0]) ? '5, 5' : null
}}
>
<Tooltip permanent direction="top" offset={[0, -10]}>
<span style={{ fontWeight: 'bold', color: color, fontSize: '0.8rem' }}>{name} ENTRY</span>
</Tooltip>
</CircleMarker>
);
})}
{/* Render Waypoint Pool */}
{activeAirportConfig && activeAirportConfig.waypoints && (
Object.values(activeAirportConfig.waypoints).map((wp) => {
const pos = xyToLatLon(wp.x, wp.y, mapAnchor);
const isIAF = wp.is_iaf || wp.name?.includes("IAF");
const isFAF = wp.is_faf || wp.name?.includes("FAF");
const isDP = wp.name?.includes("DP");
// Color logic: Departure (Greenish), IAF (Purple), FAF (Orange/Gray)
const color = isIAF ? "#4B0082" : (isDP ? "#28a745" : "#555");
const label = isIAF ? "IAF" : (isFAF ? "FAF" : (isDP ? "DP" : "WP"));
return (
<Marker
key={`wp-pool-${wp.id}`}
position={pos}
icon={createWaypointIcon(label, color, 0.9, isIAF, isFAF)}
eventHandlers={{
click: (e) => {
if (draftingMode === 'route') {
if (e.originalEvent) e.originalEvent.stopPropagation();
setStarDraft(prev => ({ ...prev, sequence: [...prev.sequence, wp.id] }));
} else if (draftingMode === 'sid_route') {
if (e.originalEvent) e.originalEvent.stopPropagation();
setSidDraft(prev => ({ ...prev, sequence: [...prev.sequence, wp.id] }));
}
},
mouseover: () => {
if (hoverTimer.current) clearTimeout(hoverTimer.current);
hoverTimer.current = setTimeout(() => setHoveredWaypoint(wp), 150);
},
mouseout: () => {
if (hoverTimer.current) clearTimeout(hoverTimer.current);
setHoveredWaypoint(null);
}
}}
>
<Tooltip direction="top" offset={[0, -10]}>
<strong>{wp.name}</strong>
{draftingMode === 'route' && <div style={{ color: 'blue' }}>Click to add to route</div>}
</Tooltip>
</Marker>
);
})
)}
{/* Render ACTIVE (Saved) STAR Lines */}
{activeAirportConfig && activeAirportConfig.stars && (
Object.entries(activeAirportConfig.stars).map(([gateId, runwayMap]) => {
const gateColor = GATE_COLORS[gateId.toUpperCase()] || '#888';
return Object.entries(runwayMap || {}).map(([runwayId, waypointIds]) => {
// Resolve IDs to Coords
const positions = (waypointIds || [])
.map(id => activeAirportConfig.waypoints[id])
.filter(Boolean)
.map(wp => xyToLatLon(wp.x, wp.y, mapAnchor));
// Draw the Route Line
return positions.length > 1 ? (
<Polyline
key={`star-line-${gateId}-${runwayId}`}
positions={positions}
color={gateColor}
weight={2}
opacity={0.4}
dashArray="5, 10"
/>
) : null;
});
})
)}
{/* Render ACTIVE (Saved) SID Lines */}
{activeAirportConfig && activeAirportConfig.sids && (
Object.entries(activeAirportConfig.sids).map(([runwayId, gateMap]) => {
return Object.entries(gateMap || {}).map(([gateId, waypointIds]) => {
const gateColor = GATE_COLORS[gateId.toUpperCase()] || '#888';
const positions = (waypointIds || [])
.map(id => activeAirportConfig.waypoints[id])
.filter(Boolean)
.map(wp => xyToLatLon(wp.x, wp.y, mapAnchor));
return positions.length > 1 ? (
<Polyline
key={`sid-line-${runwayId}-${gateId}`}
positions={positions}
color={gateColor}
weight={2}
opacity={0.4}
dashArray="5, 10"
/>
) : null;
});
})
)}
{/* Render CURRENT Route Draft Line (Flare!) */}
{draftingMode === 'route' && starDraft.sequence.length > 0 && (
<Polyline
positions={starDraft.sequence
.map(id => activeAirportConfig.waypoints[id])
.filter(Boolean)
.map(wp => xyToLatLon(wp.x, wp.y, mapAnchor))
}
color={GATE_COLORS[starDraft.gate.toUpperCase()] || '#007bff'}
weight={4}
opacity={0.6}
dashArray="10, 10"
/>
)}
{/* Render CURRENT SID Draft Line (Flare!) */}
{draftingMode === 'sid_route' && sidDraft.sequence.length > 0 && (
<Polyline
positions={sidDraft.sequence
.map(id => activeAirportConfig.waypoints[id])
.filter(Boolean)
.map(wp => xyToLatLon(wp.x, wp.y, mapAnchor))
}
color={GATE_COLORS[sidDraft.gate.toUpperCase()] || '#38a169'}
weight={4}
opacity={0.6}
dashArray="10, 10"
/>
)}
{/* Existing Airports */}
{airports.map(ap => {
const isActive = activeAirport?.name === ap.name;
return (
<React.Fragment key={ap.name}>
{/* Interaction Point (static screen size) */}
<CircleMarker
center={[ap.lat, ap.lon]}
radius={isActive ? airportRadius * 1.5 : airportRadius}
color={isActive ? "#007bff" : "#666"}
fillColor={isActive ? "#fff" : "#999"}
fillOpacity={1}
weight={2}
eventHandlers={{ click: () => onSelectAirport(ap) }}
>
<Tooltip permanent direction="top" offset={[0, -10]}>
{ap.name}
</Tooltip>
</CircleMarker>
</React.Fragment>
);
})}
{/* Active Airport Runways (Styled as realistic runways) */}
{activeAirport?.runways?.map((rw, i) => {
const is_runway_active = activeRunways.includes(rw.id);
return (
<React.Fragment key={i}>
{/* The tarmac */}
<Polyline
positions={[[rw.start[0], rw.start[1]], [rw.end[0], rw.end[1]]]}
color={is_runway_active ? "#111" : "#333"}
weight={runwayWidth}
opacity={0.8}
/>
{/* Active Glow */}
{is_runway_active && (
<Polyline
positions={[[rw.start[0], rw.start[1]], [rw.end[0], rw.end[1]]]}
color="#28a745"
weight={runwayWidth + 4}
opacity={0.2}
/>
)}
{/* The white dashed center-line */}
<Polyline
positions={[[rw.start[0], rw.start[1]], [rw.end[0], rw.end[1]]]}
color={is_runway_active ? "#28a745" : "#fff"}
weight={centerLineWidth}
dashArray={`${centerLineWidth * 5}, ${centerLineWidth * 5}`}
opacity={0.9}
/>
</React.Fragment>
);
})}
{/* Runway Drafting Visualization */}
{runwayPoints.length === 1 && (
<>
<CircleMarker center={runwayPoints[0]} radius={5 * zoomScale} color="red" />
{mousePos && (
<Polyline
positions={[runwayPoints[0], mousePos]}
color="#333"
weight={runwayWidth * 0.7}
opacity={0.5}
dashArray="5, 10"
/>
)}
</>
)}
{/* Airspace Nodes & Edges (Optional) */}
{airspace.edges.map((edge, idx) => {
const fromNode = airspace.nodes.find(n => n.id === edge.from);
const toNode = airspace.nodes.find(n => n.id === edge.to);
if (fromNode && toNode) {
return (
<Polyline key={idx} positions={[fromNode.position, toNode.position]} color="#999" dashArray="5, 10" />
);
}
return null;
})}
{airspace.nodes.map(node => (
<Circle key={node.id} center={node.position} radius={1000} color="red" />
))}
{/* Flights */}
{flights.map((flight) => {
const pos = xyToLatLon(flight.x, flight.y, mapAnchor);
const isSelected = selectedFlight && selectedFlight.callsign === flight.callsign;
const planeScale = Math.max(0.8, 1 * zoomScale);
return (
<React.Fragment key={flight.callsign}>
{/* Plot plane history */}
{flight.history && (
<Polyline positions={flight.history.map(h => xyToLatLon(h[0], h[1], mapAnchor))} color="gray" weight={2 * zoomScale} opacity={0.6} />
)}
{/* If selected, highlight with a circle marker */}
{isSelected && (
<CircleMarker center={pos} radius={15 * zoomScale} color="blue" fillOpacity={0.2} pathOptions={{ dashArray: "5, 5" }} />
)}
{/* The plane itself */}
<Marker
position={pos}
icon={createPlaneIcon(flight.heading, isSelected, planeScale)}
eventHandlers={{
click: () => onSelectFlight(flight)
}}
/>
</React.Fragment>
);
})}
<MapClickHandler />
{clickedInfo && (
<div key={toastKey} className="coordinate-toast">
<strong>Lat/Lon:</strong> {clickedInfo.lat.toFixed(6)}, {clickedInfo.lng.toFixed(6)}<br />
<strong>X/Y:</strong> {clickedInfo.x.toFixed(2)}km, {clickedInfo.y.toFixed(2)}km
</div>
)}
</MapContainer>
{/* Wind Indicator Overlay */}
<div className="wind-indicator" style={{
position: 'absolute',
top: '20px',
right: '20px',
background: 'rgba(0,0,0,0.7)',
color: '#fff',
padding: '10px 15px',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
gap: '12px',
zIndex: 1000,
border: '1px solid #444',
boxShadow: '0 4px 15px rgba(0,0,0,0.5)',
pointerEvents: 'none',
backdropFilter: 'blur(4px)',
fontFamily: 'monospace'
}}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<span style={{ fontSize: '0.7rem', opacity: 0.7, textTransform: 'uppercase', letterSpacing: '1px' }}>Wind</span>
<span style={{ fontSize: '1.1rem', fontWeight: 'bold' }}>{Math.round(windHeading)}° / {Math.round(windSpeed)}kts</span>
</div>
<div style={{
width: '32px',
height: '32px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: '#222',
borderRadius: '50%',
border: '2px solid #00ff00',
transform: `rotate(${(windHeading + 180) % 360}deg)`,
transition: 'transform 0.5s ease-in-out'
}}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#00ff00" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<polyline points="19 12 12 5 5 12"></polyline>
</svg>
</div>
</div>
</div>
);
}