Spaces:
Sleeping
Sleeping
| 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='© 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> | |
| ); | |
| } | |