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: `
`,
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
);
}