diff --git a/src/components/divisions/AirportPointEditor.jsx b/src/components/divisions/AirportPointEditor.jsx index c84ab99..a53af15 100644 --- a/src/components/divisions/AirportPointEditor.jsx +++ b/src/components/divisions/AirportPointEditor.jsx @@ -4,10 +4,25 @@ import PropTypes from 'prop-types'; import { MapContainer, TileLayer, useMap, Rectangle, Polyline } from 'react-leaflet'; import L from 'leaflet'; import { getVatsimToken } from '../../utils/cookieUtils'; -import { X, ExternalLink } from 'lucide-react'; +import { + X, + ExternalLink, + Check, + Plus, + SquarePen, + Trash2, + RotateCcw, + ChevronLeft, + ChevronRight, + Route, + CircleFadingPlus, + Loader, + MapPinPlus, +} from 'lucide-react'; import { Dropdown } from '../shared/Dropdown'; import { Layout } from '../layout/Layout'; import { Toast } from '../shared/Toast'; +import { Button } from '../shared/Button'; import '@geoman-io/leaflet-geoman-free'; import '@geoman-io/leaflet-geoman-free/dist/leaflet-geoman.css'; import 'leaflet/dist/leaflet.css'; @@ -33,6 +48,44 @@ const formatLabel = (str) => { .join(' '); }; +/** + * Parse a coordinate string in either decimal or DMS format. + * Decimal examples: "-27.3815, 153.1314" or "-27.3815 153.1314" + * DMS examples: "27°22'53.5"S 153°07'53.0"E" or "37°39'47.52"S 144°50'51.69"E" + * Returns { lat, lng } or null if unparseable. + */ +const parseCoordinateString = (input) => { + if (!input || typeof input !== 'string') return null; + const trimmed = input.trim(); + if (!trimmed) return null; + + // Try DMS format: 27°22'53.5"S 153°07'53.0"E (various quote styles) + const dmsRegex = + /(-?\d+)[°]\s*(\d+)[′']\s*([\d.]+)[″"]\s*([NSns])\s*[,;\s]+\s*(-?\d+)[°]\s*(\d+)[′']\s*([\d.]+)[″"]\s*([EWew])/; + const dmsMatch = trimmed.match(dmsRegex); + if (dmsMatch) { + let lat = + parseFloat(dmsMatch[1]) + parseFloat(dmsMatch[2]) / 60 + parseFloat(dmsMatch[3]) / 3600; + if (dmsMatch[4].toUpperCase() === 'S') lat = -lat; + let lng = + parseFloat(dmsMatch[5]) + parseFloat(dmsMatch[6]) / 60 + parseFloat(dmsMatch[7]) / 3600; + if (dmsMatch[8].toUpperCase() === 'W') lng = -lng; + if (Number.isFinite(lat) && Number.isFinite(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180) + return { lat, lng }; + } + + // Try decimal format: "-27.3815, 153.1314" or "-27.3815 153.1314" + const parts = trimmed.split(/[,;\s]+/).filter(Boolean); + if (parts.length >= 2) { + const lat = parseFloat(parts[0]); + const lng = parseFloat(parts[1]); + if (Number.isFinite(lat) && Number.isFinite(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180) + return { lat, lng }; + } + + return null; +}; + const MIN_SEGMENT_POINT_DISTANCE_METERS = 1; // 1 meter const defaultChangeset = () => ({ create: [], modify: {}, delete: [] }); @@ -1177,7 +1230,7 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = ' const navigate = useNavigate(); const [remotePoints, setRemotePoints] = useState(null); // null = not loaded const [remoteLoading, setRemoteLoading] = useState(false); - const [remoteError, setRemoteError] = useState(null); + const [, setRemoteError] = useState(null); const [uploadState, setUploadState] = useState({ status: 'idle', message: '' }); // uploading|success|error|idle const [showToast, setShowToast] = useState(false); const [toastConfig, setToastConfig] = useState({ @@ -1290,6 +1343,7 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = ' }; }, [icao, isEuroscopeOnly]); const [changeset, setChangeset] = useState(defaultChangeset); + const [showReviewPanel, setShowReviewPanel] = useState(false); const [selectedId, setSelectedId] = useState(null); const [formState, setFormState] = useState(emptyFormState); const [formErrors, setFormErrors] = useState([]); @@ -1305,6 +1359,11 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = ' const editUndoStackRef = useRef({}); const lastEditCoordsRef = useRef({}); const drawingCoordsRef = useRef([]); + const [manualCoordsMode, setManualCoordsMode] = useState(false); + const [manualCoords, setManualCoords] = useState([{ value: '' }, { value: '' }]); + const [manualCoordsErrors, setManualCoordsErrors] = useState([]); + const [manualGenerateState, setManualGenerateState] = useState('idle'); // idle | generating | generated + const [manualPlacedId, setManualPlacedId] = useState(null); // temp id of placed-but-not-yet-continued feature useEffect(() => { drawingCoordsRef.current = drawingCoords; }, [drawingCoords]); @@ -1384,8 +1443,35 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = ' const onKey = (e) => { const isMac = navigator.platform.toLowerCase().includes('mac'); const mod = isMac ? e.metaKey : e.ctrlKey; - if (!mod) return; const key = (e.key || '').toLowerCase(); + if (key === 'escape') { + if (creatingNew && !selectedId) { + e.preventDefault(); + e.stopPropagation(); + // Inline cancel logic (cancelNewDrawing is defined after this effect) + if (manualPlacedId) { + const layer = featureLayerMapRef.current[manualPlacedId]; + if (layer) layer.remove(); + setChangeset((prev) => ({ + ...prev, + create: prev.create.filter((c) => c._tempId !== manualPlacedId), + })); + delete featureLayerMapRef.current[manualPlacedId]; + } + setCreatingNew(false); + setDrawingCoords([]); + setManualCoordsMode(false); + setManualCoords([{ value: '' }, { value: '' }]); + setManualCoordsErrors([]); + setManualGenerateState('idle'); + setManualPlacedId(null); + if (mapInstanceRef.current?.pm) { + mapInstanceRef.current.pm.disableDraw(); + } + } + return; + } + if (!mod) return; // Undo: Ctrl/Cmd+Z if (key === 'z' && !e.shiftKey) { e.preventDefault(); @@ -1397,7 +1483,7 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = ' // Use capture phase to win over any handlers inside the map canvas window.addEventListener('keydown', onKey, { capture: true }); return () => window.removeEventListener('keydown', onKey, { capture: true }); - }, [performUndo]); + }, [performUndo, creatingNew, selectedId, manualPlacedId]); const startAddPoint = useCallback(() => { if (!mapInstanceRef.current) return; @@ -1406,6 +1492,12 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = ' // entering drawing mode — undo/redo uses drawing stacks only setCreatingNew(true); setDrawingCoords([]); + // Ensure manual coords state is clean + setManualCoordsMode(false); + setManualCoords([{ value: '' }, { value: '' }]); + setManualCoordsErrors([]); + setManualGenerateState('idle'); + setManualPlacedId(null); setFormState(emptyFormState); map.pm.enableDraw('Line', { snappable: true, @@ -1420,8 +1512,23 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = ' // Cancel in-progress placement (before a new temp feature is created/selected) const cancelNewDrawing = useCallback(() => { + // If we placed a manual-coords feature but haven't continued yet, remove it + if (manualPlacedId) { + const layer = featureLayerMapRef.current[manualPlacedId]; + if (layer) layer.remove(); + setChangeset((prev) => ({ + ...prev, + create: prev.create.filter((c) => c._tempId !== manualPlacedId), + })); + delete featureLayerMapRef.current[manualPlacedId]; + } setCreatingNew(false); setDrawingCoords([]); + setManualCoordsMode(false); + setManualCoords([{ value: '' }, { value: '' }]); + setManualCoordsErrors([]); + setManualGenerateState('idle'); + setManualPlacedId(null); if (mapInstanceRef.current?.pm) { mapInstanceRef.current.pm.disableDraw(); } @@ -1432,7 +1539,7 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = ' setSelectedId(null); setFormState(emptyFormState); } - }, [selectedId]); + }, [selectedId, manualPlacedId]); const handleRemoveUnsavedNew = useCallback( (targetId) => { @@ -1518,6 +1625,29 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = ' return []; }, []); + const getOutOfBoundsCoordinates = useCallback( + (coords = []) => { + if ( + !airportMeta || + !Number.isFinite(airportMeta.bbox_min_lat) || + !Number.isFinite(airportMeta.bbox_min_lon) || + !Number.isFinite(airportMeta.bbox_max_lat) || + !Number.isFinite(airportMeta.bbox_max_lon) + ) { + return []; + } + + return coords.filter( + (c) => + c.lat < airportMeta.bbox_min_lat || + c.lat > airportMeta.bbox_max_lat || + c.lng < airportMeta.bbox_min_lon || + c.lng > airportMeta.bbox_max_lon + ); + }, + [airportMeta] + ); + const pushGeometryChange = useCallback( (layer) => { if (!layer) return; @@ -1552,6 +1682,15 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = ' if (creatingNew && !isNew) { return; } + // If user clicks a manually-placed feature directly on the map (bypassing Next Step), + // reset the manual coords state so the next Add New Object starts fresh. + if (manualCoordsMode) { + setManualCoordsMode(false); + setManualCoords([{ value: '' }, { value: '' }]); + setManualCoordsErrors([]); + setManualGenerateState('idle'); + setManualPlacedId(null); + } setSelectedId(id); // initialize per-object edit history const currentCoords = extractCoords(layer); @@ -1668,9 +1807,116 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = ' styleLayerByPoint(lyr, ptData, pid === id, isDeleted); }); }, - [existingMap, changeset, pushGeometryChange, extractCoords, creatingNew] + [existingMap, changeset, pushGeometryChange, extractCoords, creatingNew, manualCoordsMode] ); + // Place a new object on the map from manually-entered coordinates (generate step) + const handleManualCoordsPlace = useCallback(() => { + const parsed = manualCoords.map((c) => parseCoordinateString(c.value)); + const errors = []; + parsed.forEach((p, idx) => { + if (!p) errors.push(`Vertex ${idx + 1}: invalid or unrecognised coordinate format.`); + }); + const validCoords = parsed.filter(Boolean); + if (validCoords.length < 2 && errors.length === 0) { + errors.push('At least 2 valid coordinates are required.'); + } + if (errors.length > 0) { + setManualCoordsErrors(errors); + return; + } + + // Validate that all coordinates fall within the airport bounding box + const outOfBounds = getOutOfBoundsCoordinates(validCoords); + if (outOfBounds.length > 0) { + setManualCoordsErrors([ + `${outOfBounds.length === validCoords.length ? 'All' : outOfBounds.length} of the entered coordinates are outside the airport boundary. Please check your coordinates and try again.`, + ]); + setToastConfig({ + title: 'Coordinates Not Allowed', + description: + 'One or more vertices are outside the airport boundary box. Coordinates must be within the defined airport area.', + variant: 'warning', + }); + setShowToast(true); + return; + } + + setManualCoordsErrors([]); + setManualGenerateState('generating'); + + const map = mapInstanceRef.current; + if (!map) { + setManualGenerateState('idle'); + return; + } + map.pm.disableDraw(); + + // If a previous generation exists, remove it first + if (manualPlacedId) { + const oldLayer = featureLayerMapRef.current[manualPlacedId]; + if (oldLayer) oldLayer.remove(); + setChangeset((prev) => ({ + ...prev, + create: prev.create.filter((c) => c._tempId !== manualPlacedId), + })); + delete featureLayerMapRef.current[manualPlacedId]; + } + + const latlngs = validCoords.map((c) => [c.lat, c.lng]); + const assignedId = `new_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const layer = L.polyline(latlngs, { pointId: assignedId, color: '#3b82f6' }); + layer.addTo(map); + featureLayerMapRef.current[assignedId] = layer; + + // Fit map to show the new polyline + try { + const bounds = layer.getBounds(); + if (bounds.isValid()) map.fitBounds(bounds.pad(0.5)); + } catch { + /* ignore */ + } + + // Push initial geometry into changeset so overlay picks it up + const coords = latlngs.map(([lat, lng]) => ({ lat, lng })); + setChangeset((prev) => ({ + ...prev, + create: [ + ...prev.create, + { _tempId: assignedId, type: 'stopbar', name: '', coordinates: coords }, + ], + })); + + setManualPlacedId(assignedId); + // Small delay for visual feedback + setTimeout(() => setManualGenerateState('generated'), 350); + }, [manualCoords, manualPlacedId, getOutOfBoundsCoordinates]); + + // Continue from manual coords generate step → select the placed feature and enter edit form + const handleManualCoordsContinue = useCallback(() => { + if (!manualPlacedId) return; + const layer = featureLayerMapRef.current[manualPlacedId]; + if (!layer) return; + + // Register selection (sets selectedId, populates form, enables vertex editing) + registerSelect(layer, manualPlacedId, true); + + // Push geometry change to changeset + try { + pushGeometryChange(layer); + } catch { + /* ignore */ + } + + // Reset manual entry state + setManualCoordsMode(false); + setManualCoords([{ value: '' }, { value: '' }]); + setManualCoordsErrors([]); + setManualGenerateState('idle'); + setManualPlacedId(null); + setDrawingCoords([]); + }, [manualPlacedId, registerSelect, pushGeometryChange]); + const handleCancelEdit = useCallback(() => { if (!selectedId) return; if (selectedId.startsWith('new_')) { @@ -1806,6 +2052,18 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = ' setFormErrors(errors); if (errors.length) return; + const outOfBoundsCoordinates = getOutOfBoundsCoordinates(coordinates); + if (outOfBoundsCoordinates.length > 0) { + setToastConfig({ + title: 'Coordinates Not Allowed', + description: + 'One or more vertices are outside the airport boundary box. Coordinates must be within the defined airport area.', + variant: 'warning', + }); + setShowToast(true); + return; + } + setChangeset((prev) => { const next = { ...prev }; if (selectedId.startsWith('new_')) { @@ -1860,6 +2118,45 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = ' return a.every((p, i) => p.lat === b[i].lat && p.lng === b[i].lng); }; + const revertChange = useCallback( + (type, id) => { + if (type === 'create') { + setChangeset((prev) => ({ ...prev, create: prev.create.filter((c) => c._tempId !== id) })); + const layer = featureLayerMapRef.current[id]; + if (layer?.remove) layer.remove(); + delete featureLayerMapRef.current[id]; + if (selectedId === id) { + setSelectedId(null); + setFormState(emptyFormState); + } + } else if (type === 'modify') { + setChangeset((prev) => { + const nextModify = { ...prev.modify }; + delete nextModify[id]; + return { ...prev, modify: nextModify }; + }); + const original = existingMap[id]; + if (original) { + const layer = featureLayerMapRef.current[id]; + if (layer) { + const latlngs = original.coordinates.map((c) => [c.lat, c.lng]); + if (layer.setLatLngs && latlngs.length >= 2) layer.setLatLngs(latlngs); + else if (layer.setLatLng && latlngs.length === 1) layer.setLatLng(latlngs[0]); + styleLayerByPoint(layer, original); + } + } + } else if (type === 'delete') { + setChangeset((prev) => ({ ...prev, delete: prev.delete.filter((d) => d !== id) })); + const original = existingMap[id]; + if (original) { + const layer = featureLayerMapRef.current[id]; + if (layer) styleLayerByPoint(layer, original); + } + } + }, + [selectedId, existingMap] + ); + const handleReverse = useCallback(() => { if (!selectedId) return; const layer = featureLayerMapRef.current[selectedId]; @@ -1922,11 +2219,78 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = ' setSelectedId(null); setFormState(emptyFormState); setCreatingNew(false); + setShowReviewPanel(false); if (mapInstanceRef.current) mapInstanceRef.current.pm.disableDraw(); editUndoStackRef.current = {}; lastEditCoordsRef.current = {}; }; + const handleUpload = async () => { + const token = getVatsimToken(); + if (!token) { + setToastConfig({ + title: 'Login Required', + description: 'Please login to upload changes.', + variant: 'destructive', + }); + setShowToast(true); + return; + } + const payload = serializeChangeset(changeset); + if ( + payload.create.length === 0 && + Object.keys(payload.modify).length === 0 && + payload.delete.length === 0 + ) + return; + setUploadState({ status: 'uploading', message: 'Uploading changes…' }); + try { + const resp = await fetch( + `https://v2.stopbars.com/airports/${encodeURIComponent(icao)}/points/batch`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Vatsim-Token': token, + }, + body: JSON.stringify(payload), + } + ); + if (!resp.ok) { + let msg = ''; + try { + const j = await resp.json(); + msg = j?.error || j?.message || ''; + } catch { + /* ignore */ + } + throw new Error(msg || `Upload failed (${resp.status})`); + } + setUploadState({ status: 'success', message: 'Changes saved.' }); + setToastConfig({ + title: 'Changes Saved', + description: 'Your changes have been successfully uploaded.', + variant: 'success', + }); + setShowToast(true); + Object.entries(featureLayerMapRef.current).forEach(([, layer]) => { + if (layer?.remove) layer.remove(); + }); + featureLayerMapRef.current = {}; + resetAll(); + setRemotePoints(null); + triggerFetchPoints(true); + } catch (e) { + setUploadState({ status: 'error', message: e.message }); + setToastConfig({ + title: 'Upload Failed', + description: e.message || 'An error occurred while uploading your changes.', + variant: 'destructive', + }); + setShowToast(true); + } + }; + useEffect(() => { Object.entries(featureLayerMapRef.current).forEach(([pid, lyr]) => { const isDeleted = changeset.delete.includes(pid); @@ -2139,6 +2503,18 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = ' } return height; }, [height]); + const hasPendingChanges = + changeset.create.length > 0 || + Object.keys(changeset.modify).length > 0 || + changeset.delete.length > 0; + const totalChanges = + changeset.create.length + Object.keys(changeset.modify).length + changeset.delete.length; + + useEffect(() => { + if (showReviewPanel && !hasPendingChanges) { + setShowReviewPanel(false); + } + }, [hasPendingChanges, showReviewPanel]); if (permissionsLoading) { return ( @@ -2197,10 +2573,20 @@ const AirportPointEditor = ({ existingPoints = [], onChangesetChange, height = '
Manage objects for BARS system integration
++ Manage airport lighting data for {icao || 'ICAO'} +
Vertices by Coordinates
++ Manually place vertices on the map by entering coordinates. Useful when + the map imagery is outdated or when you need exact positioning. +
++ You can place vertices on the map using manual coordinate input. Open any + maps service like Google Maps, navigate to your desired location, and copy + the coordinates then paste them below. Both decimal and DMS formats are + supported. +
+No objects found
++ {searchQuery?.trim() + ? 'Try a different search term.' + : 'Objects will appear here once loaded or created.'} +
++ Unsaved +
++ {id} +
++ {id} +
+