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: `
`, 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: `
${label}
`, 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 (
{/* Radar Range Rings (10, 20, 30, 45 km) */} {[10000, 20000, 30000, 45000].map(r => ( {r / 1000} KM ))} {/* 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 ( {name} ENTRY ); })} {/* 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 ( { 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); } }} > {wp.name} {draftingMode === 'route' &&
Click to add to route
}
); }) )} {/* 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 ? ( ) : 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 ? ( ) : null; }); }) )} {/* Render CURRENT Route Draft Line (Flare!) */} {draftingMode === 'route' && starDraft.sequence.length > 0 && ( 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 && ( 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 ( {/* Interaction Point (static screen size) */} onSelectAirport(ap) }} > {ap.name} ); })} {/* Active Airport Runways (Styled as realistic runways) */} {activeAirport?.runways?.map((rw, i) => { const is_runway_active = activeRunways.includes(rw.id); return ( {/* The tarmac */} {/* Active Glow */} {is_runway_active && ( )} {/* The white dashed center-line */} ); })} {/* Runway Drafting Visualization */} {runwayPoints.length === 1 && ( <> {mousePos && ( )} )} {/* 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 ( ); } return null; })} {airspace.nodes.map(node => ( ))} {/* 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 ( {/* Plot plane history */} {flight.history && ( xyToLatLon(h[0], h[1], mapAnchor))} color="gray" weight={2 * zoomScale} opacity={0.6} /> )} {/* If selected, highlight with a circle marker */} {isSelected && ( )} {/* The plane itself */} onSelectFlight(flight) }} /> ); })} {clickedInfo && (
Lat/Lon: {clickedInfo.lat.toFixed(6)}, {clickedInfo.lng.toFixed(6)}
X/Y: {clickedInfo.x.toFixed(2)}km, {clickedInfo.y.toFixed(2)}km
)}
{/* Wind Indicator Overlay */}
Wind {Math.round(windHeading)}° / {Math.round(windSpeed)}kts
); }