diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 457e1429..5f182d17 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -79,7 +79,7 @@ repos: # Deps: ensure Python uv lockfile is up to date - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.5.5 + rev: 0.5.7 hooks: - id: uv-lock files: src/backend/pyproject.toml @@ -87,7 +87,7 @@ repos: # Versioning: Commit messages & changelog - repo: https://github.com/commitizen-tools/commitizen - rev: v4.0.0 + rev: v4.1.0 hooks: - id: commitizen stages: [commit-msg] @@ -95,7 +95,7 @@ repos: # Lint / autoformat: Python code - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: "v0.8.1" + rev: "v0.8.2" hooks: # Run the linter - id: ruff @@ -107,7 +107,7 @@ repos: # Autoformat: YAML, JSON, Markdown, etc. - repo: https://github.com/pycontribs/mirrors-prettier - rev: v3.3.3 + rev: v3.4.2 hooks: - id: prettier args: diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index a3872075..e6799b32 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -1,5 +1,6 @@ import json import os +import shutil import uuid from typing import Annotated, Optional from uuid import UUID @@ -39,7 +40,7 @@ ) from app.users import user_schemas from minio.deleteobjects import DeleteObject -from drone_flightplan import waypoints +from drone_flightplan import waypoints, add_elevation_from_dem router = APIRouter( prefix=f"{settings.API_PREFIX}/projects", @@ -668,6 +669,7 @@ async def get_project_waypoints_counts( meters: float = 100, project_geojson: UploadFile = File(...), is_terrain_follow: bool = False, + dem: UploadFile = File(None), user_data: AuthUser = Depends(login_required), ): """ @@ -708,6 +710,32 @@ async def get_project_waypoints_counts( generate_3d=generate_3d, take_off_point=None, ) + + # Handle terrain-following logic if a DEM is provided + points_with_elevation = points + if is_terrain_follow and dem: + temp_dir = f"/tmp/{uuid.uuid4()}" + try: + os.makedirs(temp_dir, exist_ok=True) + dem_path = os.path.join(temp_dir, "dem.tif") + outfile_with_elevation = os.path.join( + temp_dir, "output_file_with_elevation.geojson" + ) + + with open(dem_path, "wb") as dem_file: + dem_file.write(await dem.read()) + + add_elevation_from_dem(dem_path, points, outfile_with_elevation) + + with open(outfile_with_elevation, "r") as inpointsfile: + points_with_elevation = inpointsfile.read() + except Exception as e: + log.error(f"Error processing DEM: {e}") + + finally: + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir) + return { - "avg_no_of_waypoints": len(json.loads(points)["features"]), + "avg_no_of_waypoints": len(json.loads(points_with_elevation)["features"]), } diff --git a/src/backend/app/tasks/task_schemas.py b/src/backend/app/tasks/task_schemas.py index b3bf77a7..2e1c5210 100644 --- a/src/backend/app/tasks/task_schemas.py +++ b/src/backend/app/tasks/task_schemas.py @@ -267,6 +267,7 @@ class TaskDetailsOut(BaseModel): side_overlap: Optional[float] = None gsd_cm_px: Optional[float] = None gimble_angles_degrees: Optional[int] = None + centroid: dict @field_validator("state", mode="after") @classmethod @@ -312,7 +313,8 @@ async def get_task_details(db: Connection, task_id: uuid.UUID): ), 'id', tasks.id ) AS outline, - + -- Calculate the centroid of the outline + ST_AsGeoJSON(ST_Centroid(tasks.outline))::jsonb AS centroid, te.created_at, te.updated_at, te.state, diff --git a/src/backend/app/utils.py b/src/backend/app/utils.py index bcc54154..41212919 100644 --- a/src/backend/app/utils.py +++ b/src/backend/app/utils.py @@ -5,7 +5,7 @@ import json import base64 from datetime import datetime, timezone -from typing import Optional, Union, Any +from typing import Dict, Optional, Union, Any from geojson_pydantic import Feature, MultiPolygon, Polygon from geojson_pydantic import FeatureCollection as FeatCol from geoalchemy2 import WKBElement @@ -21,6 +21,9 @@ from email.mime.text import MIMEText from email.utils import formataddr from aiosmtplib import send as send_email +from shapely.geometry import Point +from shapely.ops import transform +from pyproj import Transformer log = logging.getLogger(__name__) @@ -551,3 +554,49 @@ async def send_project_approval_email_to_regulator( subject="Project Review Request for Drone Operations Approval", html_content=html_content, ) + + +def calculate_flight_time_from_placemarks(placemarks: Dict) -> Dict: + """ + Calculate the total and average flight time based on placemarks and dynamically format the output. + + Args: + placemarks (Dict): GeoJSON-like data structure with flight plan. + + Returns: + Dict: Contains formatted total flight time and segment times. + """ + total_time = 0 + features = placemarks["features"] + transformer = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True) + for i in range(1, len(features)): + # Extract current and previous coordinates + prev_coords = features[i - 1]["geometry"]["coordinates"][:2] + curr_coords = features[i]["geometry"]["coordinates"][:2] + speed = features[i]["properties"]["speed"] # Speed in m/s + + # Create Shapely Points and transform to planar coordinates for distance calculation + prev_point = Point(transform(transformer.transform, Point(prev_coords))) + curr_point = Point(transform(transformer.transform, Point(curr_coords))) + + # Calculate distance (meters) and time (seconds) + distance = prev_point.distance(curr_point) + segment_time = distance / speed + total_time += segment_time + + # Dynamically format the total flight time + hours = int(total_time // 3600) + minutes = int((total_time % 3600) // 60) + seconds = round(total_time % 60, 2) + + if total_time < 60: + formatted_time = f"{seconds} seconds" + elif total_time < 3600: + formatted_time = f"{minutes} minutes {seconds:.2f} seconds" + else: + formatted_time = f"{hours} hours {minutes} minutes {seconds:.2f} seconds" + + return { + "total_flight_time": formatted_time, + "total_flight_time_seconds": round(total_time, 2), + } diff --git a/src/backend/app/waypoints/waypoint_routes.py b/src/backend/app/waypoints/waypoint_routes.py index fab6f6ba..f038f8cc 100644 --- a/src/backend/app/waypoints/waypoint_routes.py +++ b/src/backend/app/waypoints/waypoint_routes.py @@ -1,3 +1,4 @@ +import os import uuid import geojson import shutil @@ -18,9 +19,11 @@ get_take_off_point_from_db, update_take_off_point_in_db, ) -from app.waypoints.waypoint_logic import check_point_within_buffer +from app.waypoints.waypoint_logic import ( + check_point_within_buffer, +) from app.db import database -from app.utils import merge_multipolygon +from app.utils import calculate_flight_time_from_placemarks, merge_multipolygon from app.s3 import get_file_from_bucket from typing import Annotated from psycopg import Connection @@ -28,6 +31,7 @@ from shapely.geometry import shape from app.waypoints import waypoint_schemas + # Constant to convert gsd to Altitude above ground level GSD_to_AGL_CONST = 29.7 # For DJI Mini 4 Pro @@ -144,10 +148,12 @@ async def get_task_waypoint( outfile = outfile = f"/tmp/{uuid.uuid4()}" kmz_file = wpml.create_wpml(placemarks, outfile) return FileResponse( - kmz_file, media_type="application/zip", filename="flight_plan.kmz" + kmz_file, + media_type="application/zip", + filename=f"{task_id}_flight_plan.kmz", ) - - return placemarks + flight_data = calculate_flight_time_from_placemarks(placemarks) + return {"results": placemarks, "flight_data": flight_data} @router.post("/") @@ -254,3 +260,23 @@ async def generate_kmz( return FileResponse( output_file, media_type="application/zip", filename="output.kmz" ) + + +@router.post("/{task_id}/generate-kmz/") +async def generate_kmz_with_placemarks( + task_id: uuid.UUID, data: waypoint_schemas.PlacemarksFeature +): + try: + outfile = f"/tmp/{task_id}_flight_plan.kmz" + + kmz_file = wpml.create_wpml(data.model_dump(), outfile) + if not os.path.exists(kmz_file): + raise HTTPException(status_code=500, detail="Failed to generate KMZ file.") + return FileResponse( + kmz_file, + media_type="application/zip", + filename=f"{task_id}_flight_plan.kmz", + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error generating KMZ: {str(e)}") diff --git a/src/backend/app/waypoints/waypoint_schemas.py b/src/backend/app/waypoints/waypoint_schemas.py index e20fd87c..b9299b78 100644 --- a/src/backend/app/waypoints/waypoint_schemas.py +++ b/src/backend/app/waypoints/waypoint_schemas.py @@ -1,5 +1,7 @@ import json +from typing import List, Optional from pydantic import BaseModel, model_validator +from geojson_pydantic import FeatureCollection, Feature, Point class PointField(BaseModel): @@ -12,3 +14,33 @@ def validate_to_json(cls, value): if isinstance(value, str): return cls(**json.loads(value)) return value + + +class Properties(BaseModel): + altitude: float + gimbal_angle: str + heading: float + index: int + speed: float + take_photo: bool + elevation: Optional[float] = None + + +class Geometry(Point): + pass + + +class Feature(Feature): + geometry: Geometry + properties: Properties + + +class CRS(BaseModel): + properties: dict + type: str + + +class PlacemarksFeature(FeatureCollection): + type: str = "FeatureCollection" + crs: Optional[CRS] = None + features: List[Feature] diff --git a/src/frontend/src/assets/css/index.css b/src/frontend/src/assets/css/index.css index 4c004fc3..bfb1cfd1 100644 --- a/src/frontend/src/assets/css/index.css +++ b/src/frontend/src/assets/css/index.css @@ -61,6 +61,12 @@ body { .scrollbar-images-grid::-webkit-scrollbar-thumb { background: transparent; } + +.has-dropshadow { + box-shadow: + 2px 2px 5px rgb(255, 0, 0), + 2px 2px 3px rgba(255, 0, 0, 0.692); +} @keyframes pulse-animation { 0% { box-shadow: 0 0 0 0 rgba(0, 70, 119, 0.5); diff --git a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DescriptionBox/index.tsx b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DescriptionBox/index.tsx index 9140b743..2d8b9fe7 100644 --- a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DescriptionBox/index.tsx +++ b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DescriptionBox/index.tsx @@ -86,6 +86,14 @@ const DescriptionBox = () => { }, }); + const { data: flightTimeData }: any = useGetTaskWaypointQuery( + projectId as string, + taskId as string, + { + select: ({ data }: any) => data.flight_data, + }, + ); + const { data: taskDescription }: Record = useGetIndividualTaskQuery(taskId as string, { enabled: !!taskWayPoints, @@ -126,7 +134,11 @@ const DescriptionBox = () => { { name: 'Total points', value: taskWayPoints?.length }, { name: 'Est. flight time', - value: taskData?.flight_time || null, + value: flightTimeData?.total_flight_time || null, + }, + { + name: 'Est. flight time in seconds', + value: flightTimeData?.total_flight_time_seconds || null, }, ], }, diff --git a/src/frontend/src/components/DroneOperatorTask/MapSection/index.tsx b/src/frontend/src/components/DroneOperatorTask/MapSection/index.tsx index 76420d8e..fb6eb1c4 100644 --- a/src/frontend/src/components/DroneOperatorTask/MapSection/index.tsx +++ b/src/frontend/src/components/DroneOperatorTask/MapSection/index.tsx @@ -1,16 +1,23 @@ +/* eslint-disable no-param-reassign */ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable prefer-destructuring */ /* eslint-disable react/no-array-index-key */ -import { useGetTaskAssetsInfo, useGetTaskWaypointQuery } from '@Api/tasks'; +import { + useGetIndividualTaskQuery, + useGetTaskAssetsInfo, + useGetTaskWaypointQuery, +} from '@Api/tasks'; import marker from '@Assets/images/marker.png'; import right from '@Assets/images/rightArrow.png'; import BaseLayerSwitcherUI from '@Components/common/BaseLayerSwitcher'; import { useMapLibreGLMap } from '@Components/common/MapLibreComponents'; -import AsyncPopup from '@Components/common/MapLibreComponents/AsyncPopup'; import VectorLayer from '@Components/common/MapLibreComponents/Layers/VectorLayer'; import LocateUser from '@Components/common/MapLibreComponents/LocateUser'; import MapContainer from '@Components/common/MapLibreComponents/MapContainer'; import { GeojsonType } from '@Components/common/MapLibreComponents/types'; import { Button } from '@Components/RadixComponents/Button'; import { postTaskWaypoint } from '@Services/tasks'; +import AsyncPopup from '@Components/common/MapLibreComponents/NewAsyncPopup'; import { toggleModal } from '@Store/actions/common'; import { setSelectedTakeOffPoint, @@ -37,13 +44,10 @@ import ToolTip from '@Components/RadixComponents/ToolTip'; import Skeleton from '@Components/RadixComponents/Skeleton'; import rotateGeoJSON from '@Utils/rotateGeojsonData'; import COGOrthophotoViewer from '@Components/common/MapLibreComponents/COGOrthophotoViewer'; -import { - calculateAngle, - calculateCentroid, - // calculateCentroidFromCoordinates, -} from '@Utils/index'; -import { useGetProjectsDetailQuery } from '@Api/projects'; import { toast } from 'react-toastify'; +import RotatingCircle from '@Components/common/RotationCue'; +import { mapLayerIDs } from '@Constants/droneOperator'; +import { findNearestCoordinate, swapFirstAndLast } from '@Utils/index'; import GetCoordinatesOnClick from './GetCoordinatesOnClick'; import ShowInfo from './ShowInfo'; @@ -51,22 +55,20 @@ const { COG_URL } = process.env; const MapSection = ({ className }: { className?: string }) => { const dispatch = useDispatch(); - const [rotationDegree, setRotationDegree] = useState(0); const bboxRef = useRef(); - const mapRef = useRef(); - const rotatedTaskWayPointsRef = useRef>(); const [taskWayPoints, setTaskWayPoints] = useState | null>(); - const draggingRef = useRef(false); const { projectId, taskId } = useParams(); - const centeroidRef = useRef<[number, number]>(); + const takeOffPointRef = useRef<[number, number]>(); const queryClient = useQueryClient(); const [popupData, setPopupData] = useState>({}); const [showOrthoPhotoLayer, setShowOrthoPhotoLayer] = useState(true); const [showTakeOffPoint, setShowTakeOffPoint] = useState(true); const [isRotationEnabled, setIsRotationEnabled] = useState(false); + const [rotationAngle, setRotationAngle] = useState(0); + const [dragging, setDragging] = useState(false); const { map, isMapLoaded } = useMapLibreGLMap({ containerId: 'dashboard-map', mapOptions: { @@ -80,39 +82,50 @@ const MapSection = ({ className }: { className?: string }) => { state => state.droneOperatorTask.selectedTakeOffPoint, ); - useEffect(() => { - if (!map || !isMapLoaded) return; - mapRef.current = map; - }, [map, isMapLoaded]); + function setVisibilityOfLayers(layerIds: string[], visibility: string) { + layerIds.forEach(layerId => { + map?.setLayoutProperty(layerId, 'visibility', visibility); + }); + } const { data: taskDataPolygon, - // isFetching: isProjectDataFetching, - }: Record = useGetProjectsDetailQuery(projectId as string, { + isFetching: taskDataPolygonIsFetching, + }: Record = useGetIndividualTaskQuery(taskId as string, { select: (projectRes: any) => { - const taskPolygon = projectRes.data.tasks.find( - (task: Record) => task.id === taskId, - ); - const { geometry } = taskPolygon.outline; + const taskPolygon = projectRes.data.outline; + const { geometry } = taskPolygon; return { type: 'FeatureCollection', features: [ { type: 'Feature', - geometry, + geometry: { + type: 'Polygon', + coordinates: geometry.coordinates, + }, properties: {}, }, ], }; }, + onSuccess: () => { + if (map) { + const layers = map.getStyle().layers; + if (layers && layers.length > 0) { + const firstLayerId = layers[4].id; // Get the first layer + map.moveLayer('task-polygon-layer', firstLayerId); // Move the layer before the first layer + } + } + }, }); const { data: taskWayPointsData }: any = useGetTaskWaypointQuery( projectId as string, taskId as string, { - select: (data: any) => { + select: ({ data }: any) => { const modifiedTaskWayPointsData = { - geojsonListOfPoint: data.data, + geojsonListOfPoint: data.results, geojsonAsLineString: { type: 'FeatureCollection', features: [ @@ -122,13 +135,13 @@ const MapSection = ({ className }: { className?: string }) => { geometry: { type: 'LineString', // get all coordinates - coordinates: coordAll(data.data), + coordinates: coordAll(data.results), }, }, ], }, }; - centeroidRef.current = + takeOffPointRef.current = modifiedTaskWayPointsData?.geojsonListOfPoint?.features[0]?.geometry?.coordinates; return modifiedTaskWayPointsData; }, @@ -155,7 +168,6 @@ const MapSection = ({ className }: { className?: string }) => { // zoom to task (waypoint) useEffect(() => { if (!taskWayPoints?.geojsonAsLineString || !isMapLoaded || !map) return; - const { geojsonAsLineString } = taskWayPoints; let bbox = null; // calculate bbox with with updated take-off point if (newTakeOffPoint && newTakeOffPoint !== 'place_on_map') { @@ -163,19 +175,26 @@ const MapSection = ({ className }: { className?: string }) => { type: 'FeatureCollection', features: [ // @ts-ignore - ...geojsonAsLineString.features, + ...taskDataPolygon.features, newTakeOffPoint, ], }; bbox = getBbox(combinedFeatures); } else { - bbox = getBbox(geojsonAsLineString as FeatureCollection); + bbox = getBbox(taskDataPolygon as FeatureCollection); } bboxRef.current = bbox; if (!isRotationEnabled) { - map?.fitBounds(bbox as LngLatBoundsLike, { padding: 25, duration: 500 }); + map?.fitBounds(bbox as LngLatBoundsLike, { padding: 105, duration: 500 }); } - }, [map, taskWayPoints, newTakeOffPoint, isMapLoaded, isRotationEnabled]); + }, [ + map, + taskWayPoints, + taskDataPolygon, + newTakeOffPoint, + isMapLoaded, + isRotationEnabled, + ]); const getPopupUI = useCallback(() => { return ( @@ -211,8 +230,11 @@ const MapSection = ({ className }: { className?: string }) => { }, [popupData]); const handleSaveStartingPoint = () => { - const { geometry } = newTakeOffPoint as Record; - const [lng, lat] = geometry.coordinates; + const { geometry: startingPonyGeometry } = newTakeOffPoint as Record< + string, + any + >; + const [lng, lat] = startingPonyGeometry.coordinates; postWaypoint({ projectId, taskId, @@ -253,29 +275,8 @@ const MapSection = ({ className }: { className?: string }) => { function handleTaskWayPoint() { if (!map || !isMapLoaded) return; - map.setLayoutProperty( - 'waypoint-points-layer', - 'visibility', - `${!showTakeOffPoint ? 'visible' : 'none'}`, - ); - map.setLayoutProperty( - 'waypoint-points-image-layer', - 'visibility', - `${!showTakeOffPoint ? 'visible' : 'none'}`, - ); - map.setLayoutProperty( - 'waypoint-line-layer', - 'visibility', - `${!showTakeOffPoint ? 'visible' : 'none'}`, - ); - map.setLayoutProperty( - 'waypoint-points-image-image/logo', - 'visibility', - `${!showTakeOffPoint ? 'visible' : 'none'}`, - ); - map.setLayoutProperty( - 'waypoint-line-image/logo', - 'visibility', + setVisibilityOfLayers( + mapLayerIDs, `${!showTakeOffPoint ? 'visible' : 'none'}`, ); setShowTakeOffPoint(!showTakeOffPoint); @@ -284,18 +285,63 @@ const MapSection = ({ className }: { className?: string }) => { const rotateLayerGeoJSON = ( layerIds: string[], rotationDegreeParam: number, + baseLayerIds: string[], + excludeFirstFeature?: boolean, ) => { - if (!mapRef.current) return; + if (!map || !isMapLoaded) return; - layerIds.forEach(layerId => { - const source = mapRef.current?.getSource(layerId); + baseLayerIds.forEach((baseLayerId, index) => { + const source = map?.getSource(baseLayerId); + const sourceToRotate = map?.getSource(layerIds[index]); if (source && source instanceof GeoJSONSource) { - // eslint-disable-next-line no-underscore-dangle - const geojsonData = source._data; - // @ts-ignore - const rotatedGeoJSON = rotateGeoJSON(geojsonData, rotationDegreeParam); - source.setData(rotatedGeoJSON); + const baseGeoData = source._data; + if (!baseGeoData) return; + const [firstFeature, ...restFeatures] = ( + baseGeoData as Record + ).features; + if (firstFeature.geometry.type === 'Point') { + const pointRotatedGeoJson = rotateGeoJSON( + // @ts-ignore + { + ...(baseGeoData as object), + features: excludeFirstFeature + ? restFeatures + : [firstFeature, ...restFeatures], + }, + rotationDegreeParam, + ); + if (sourceToRotate && sourceToRotate instanceof GeoJSONSource) { + // @ts-ignore + sourceToRotate.setData(pointRotatedGeoJson); + } + } + if (firstFeature.geometry.type === 'LineString') { + const [firstCoordinate, ...restCoordinates] = + firstFeature.geometry.coordinates; + const rotatedGeoJson = rotateGeoJSON( + { + features: [ + // @ts-ignore + { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: excludeFirstFeature + ? restCoordinates + : [firstCoordinate, ...restCoordinates], + }, + }, + ], + type: 'FeatureCollection', + }, + rotationDegreeParam, + ); + if (sourceToRotate && sourceToRotate instanceof GeoJSONSource) { + // @ts-ignore + sourceToRotate.setData(rotatedGeoJson); + } + } } }); }; @@ -321,90 +367,121 @@ const MapSection = ({ className }: { className?: string }) => { }; }, [taskWayPointsData]); - useEffect(() => { - if (!rotatedTaskWayPoints) return; - rotatedTaskWayPointsRef.current = rotatedTaskWayPoints; - }, [rotatedTaskWayPoints]); - - // function that handles drag and calculates rotation - const handleDrag = useCallback((event: any) => { - if (!draggingRef.current) { - draggingRef.current = true; - } - const { originalCoordinates } = event; - const centroidCoordinates = calculateCentroid(bboxRef.current || []); - - const calculatedAngleFromCoordinates = calculateAngle( - [originalCoordinates[0], originalCoordinates[1]], - [event.lngLat?.lng, event.lngLat?.lat], - [centroidCoordinates.lng, centroidCoordinates.lat], - ); - // Update rotation continuously while dragging - setRotationDegree(calculatedAngleFromCoordinates); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + function updateLayerCoordinates( + layerIds: Record[], + coordinate: [number, number], + ) { + // Iterate over the array of layer IDs + if (!map || !isMapLoaded) return; + layerIds.forEach(layerId => { + // Check if the layer is of type 'symbol' (or any other type) + const source = map.getSource(layerId.id); // Get the source of the layer - function getGeoJSONDataFromMap(sourceIds: string[]) { - const geojsonData: Record = {}; + // Update the feature on the map - if (!mapRef.current) return geojsonData; - sourceIds.forEach(sourceId => { - const source = mapRef.current?.getSource(sourceId); if (source && source instanceof GeoJSONSource) { - // eslint-disable-next-line no-underscore-dangle - geojsonData[sourceId] = source._data; + const geoJsonData = source._data; + // @ts-ignore + const { features, ...restGeoData } = geoJsonData; + const coordinates = features[0].geometry.coordinates; + if (layerId.type === 'MultiString') { + const nearestCoordinate = findNearestCoordinate( + coordinates[0], + coordinates[coordinates.length - 1], + takeOffPointRef.current || [0, 0], + ); + let indexToReplace = 0; + if (nearestCoordinate === 'second') { + indexToReplace = coordinates.length; + } + features[0].geometry.coordinates[indexToReplace] = coordinate; + const updatedLineStringData = features[0].geometry.coordinates; + if (indexToReplace !== 0) { + updatedLineStringData.reverse().pop(); + } + source.setData({ features, ...restGeoData }); + } + if (layerId.type === 'Points') { + const nearestPoint = findNearestCoordinate( + coordinates, + features[features.length - 1].geometry.coordinates, + takeOffPointRef.current || [0, 0], + ); + let pointIndexToReplace = 0; + if (nearestPoint === 'second') { + pointIndexToReplace = features.length; + } + if (pointIndexToReplace !== 0) { + features.push({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [], + }, + properties: {}, + }); + } + features[pointIndexToReplace].geometry.coordinates = coordinate; + const rotatedFeatures = features; + if (pointIndexToReplace !== 0) { + swapFirstAndLast(rotatedFeatures); + features.pop(); + } + source.setData({ features, ...restGeoData }); + } } }); - - return geojsonData; } - // function to handle end of drag - const handleDragEnd = useCallback(() => { - const dataRetrieved = getGeoJSONDataFromMap([ - 'rotated-waypoint-line', - 'rotated-waypoint-points', - 'rotated-waypoint-points-image', - ]); - const newTaskGeoJson = { - geojsonAsLineString: dataRetrieved['rotated-waypoint-line'], - geojsonListOfPoint: dataRetrieved['rotated-waypoint-points'], - }; - setTaskWayPoints(newTaskGeoJson); - if (draggingRef.current) { - draggingRef.current = false; // Reset dragging state + useEffect(() => { + if (!dragging) { + rotateLayerGeoJSON( + ['waypoint-line', 'waypoint-points'], + rotationAngle, + ['waypoint-line', 'waypoint-points'], + false, + ); + updateLayerCoordinates( + [ + { id: 'waypoint-line', type: 'MultiString' }, + { id: 'waypoint-points', type: 'Points' }, + ], + takeOffPointRef.current || [0, 0], + ); + + return; } - }, []); + rotateLayerGeoJSON( + ['rotated-waypoint-line', 'rotated-waypoint-points'], + rotationAngle, + ['waypoint-line', 'waypoint-points'], + true, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rotationAngle, dragging]); function handleRotationToggle() { if (!map || !isMapLoaded) return; setIsRotationEnabled(!isRotationEnabled); - if (!isRotationEnabled) { - map.dragPan.disable(); - } else { - map.dragPan.enable(); - } } + useEffect(() => { + if (!map || !isMapLoaded) return; - // Call the function to update rotation for each layer - rotateLayerGeoJSON( - [ - 'rotated-waypoint-line', - 'rotated-waypoint-points', - 'rotated-waypoint-points-image', - ], - rotationDegree, - ); + if (!dragging) { + setVisibilityOfLayers(mapLayerIDs, 'visible'); + return; + } + setVisibilityOfLayers(mapLayerIDs, 'none'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dragging, isMapLoaded, map]); return ( <>
{ - {taskWayPoints && ( + {taskWayPoints && !taskDataPolygonIsFetching && ( <> {/* render line */} { image={right} symbolPlacement="line" iconAnchor="center" - onDrag={handleDrag} - onDragEnd={handleDragEnd} - needDragEvent={isRotationEnabled} /> {/* render points */} { ], }, }} - onDrag={handleDrag} - onDragEnd={handleDragEnd} - needDragEvent={isRotationEnabled} /> {/* render image and only if index is 0 */} { imageLayerOptions={{ filter: ['==', 'index', 0], }} - onDrag={handleDrag} - onDragEnd={handleDragEnd} /> )} - {isRotationEnabled && draggingRef.current && ( + {isRotationEnabled && dragging && ( <> {/* render line */} { ], }, }} - onDrag={handleDrag} - onDragEnd={handleDragEnd} - /> - {/* render image and only if index is 0 */} - )} -
-
+
+
+ )} +
+ ); +} + +const AsyncPopup = forwardRef( + ( + { + map, + fetchPopupData, + popupUI, + title, + handleBtnClick, + isLoading = false, + onClose, + hideButton = false, + getCoordOnProperties = false, + showPopup = (_clickedFeature: Record) => true, + openPopupFor, + popupCoordinate, + }: IAsyncPopup, + ref, + ) => { + const [properties, setProperties] = useState | null>( + null, + ); + const [coordinates, setCoordinates] = useState(); + const [isPopupOpen, setIsPopupOpen] = useState(false); + + useEffect(() => { + if (!map) return; + function displayPopup(e: MapMouseEvent): void { + if (!map) return; + const features = map.queryRenderedFeatures(e.point); + const clickedFeature = features?.[0]; + + // in case of popup rendering conditionally + if (!showPopup(clickedFeature)) return; + + if (!clickedFeature) return; + setProperties( + getCoordOnProperties + ? { + ...clickedFeature.properties, + layer: clickedFeature.source, + coordinates: e.lngLat, + } + : { + ...clickedFeature.properties, + layer: clickedFeature.source, + }, + ); + + setCoordinates(e.lngLat as unknown as number[]); + // popup.setLngLat(e.lngLat); + } + map.on('click', displayPopup); + }, [map, getCoordOnProperties, showPopup]); + + useEffect(() => { + if (!map || !properties) return; + fetchPopupData?.(properties); + }, [map, properties]); // eslint-disable-line + + useEffect(() => { + if (!map || !properties || !popupUI) return; + const htmlString = renderToString( + void} + />, + ); + popup.setHTML(htmlString).addTo(map); + popup.setLngLat(coordinates as LngLatLike); + setIsPopupOpen(true); + }, [ + handleBtnClick, + hideButton, + isLoading, + map, + onClose, + popupUI, + properties, + title, + coordinates, + ]); + + useEffect(() => { + if (!map || !openPopupFor || !popupCoordinate) return; + setProperties(openPopupFor); + setCoordinates(popupCoordinate); + }, [map, openPopupFor, popupCoordinate]); + + useEffect(() => { + const closeBtn = document.getElementById('popup-close-button'); + const popupBtn = document.getElementById('popup-button'); + + const handleCloseBtnClick = () => { + popup.remove(); + onClose?.(); + setProperties(null); + setIsPopupOpen(false); + }; + + const handlePopupBtnClick = () => { + if (!properties) return; + handleBtnClick?.(properties); + }; + + closeBtn?.addEventListener('click', handleCloseBtnClick); + popupBtn?.addEventListener('click', handlePopupBtnClick); + + return () => { + closeBtn?.removeEventListener('click', handleCloseBtnClick); + popupBtn?.removeEventListener('click', handlePopupBtnClick); + }; + }, [onClose, isPopupOpen, properties, handleBtnClick]); + + if (!properties) return
; + return null; + }, +); +export default AsyncPopup; diff --git a/src/frontend/src/components/common/RotationCue/index.tsx b/src/frontend/src/components/common/RotationCue/index.tsx new file mode 100644 index 00000000..1eba21f5 --- /dev/null +++ b/src/frontend/src/components/common/RotationCue/index.tsx @@ -0,0 +1,84 @@ +/* eslint-disable no-unused-vars */ +import React, { useState } from 'react'; + +type RotationCueProps = { + setRotation: (rotation: number) => void; + rotation: number; + dragging: boolean; + setDragging: (dragging: boolean) => void; +}; +const RotationCue = ({ + setRotation, + rotation, + setDragging, + dragging, +}: RotationCueProps) => { + const [startAngle, setStartAngle] = useState(0); + + const handleMouseDown = (event: React.MouseEvent) => { + const { clientX, clientY } = event; + const circle = (event.target as HTMLElement).getBoundingClientRect(); + const centerX = circle.left + circle.width / 2; + const centerY = circle.top + circle.height / 2; + + const radians = Math.atan2(clientY - centerY, clientX - centerX); + const degrees = (radians * (180 / Math.PI) + 360) % 360; + setStartAngle(degrees - rotation); // Offset for smooth dragging + setDragging(true); + }; + + const handleMouseMove = (event: React.MouseEvent) => { + if (!dragging) return; + + const { clientX, clientY } = event; + const circle = (event.target as HTMLElement).getBoundingClientRect(); + const centerX = circle.left + circle.width / 2; + const centerY = circle.top + circle.height / 2; + + const radians = Math.atan2(clientY - centerY, clientX - centerX); + const degrees = (radians * (180 / Math.PI) + 360) % 360; + + let rotationDelta = degrees - startAngle; + if (rotationDelta < 0) { + rotationDelta += 360; + } + + setRotation(rotationDelta); + }; + + const handleMouseUp = () => { + setDragging(false); + }; + + return ( +
+
+ {/* Rotating Line */} +
+ + {/* Static Center */} +

+ {rotation.toFixed(2)} +

+
+
+
+ ); +}; + +export default RotationCue; diff --git a/src/frontend/src/constants/droneOperator.ts b/src/frontend/src/constants/droneOperator.ts index 8624cc25..6a70bac7 100644 --- a/src/frontend/src/constants/droneOperator.ts +++ b/src/frontend/src/constants/droneOperator.ts @@ -29,3 +29,11 @@ export const descriptionData = [ ], ]; export const descriptionTitle = ['Task Description', 'Flight Parameters']; + +export const mapLayerIDs = [ + 'waypoint-points-layer', + 'waypoint-points-image-layer', + 'waypoint-line-layer', + 'waypoint-points-image-image/logo', + 'waypoint-line-image/logo', +]; diff --git a/src/frontend/src/utils/index.ts b/src/frontend/src/utils/index.ts index c39b93a3..bf63d50d 100644 --- a/src/frontend/src/utils/index.ts +++ b/src/frontend/src/utils/index.ts @@ -213,3 +213,35 @@ export function calculateCentroidFromCoordinates(coordinates: any[]) { // Convert the results to degrees return [RadtoDegrees(lat), RadtoDegrees(lon)]; } + +export function findNearestCoordinate( + coord1: number[], + coord2: number[], + center: number[], +) { + // Function to calculate distance between two points + const calculateDistance = (point1: number[], point2: number[]) => { + const xDiff = point2[0] - point1[0]; + const yDiff = point2[1] - point1[1]; + return Math.sqrt(xDiff * xDiff + yDiff * yDiff); + }; + + // Calculate the distance of the first and second coordinates from the center + const distance1 = calculateDistance(coord1, center); + const distance2 = calculateDistance(coord2, center); + + // Return the nearest coordinate + return distance1 <= distance2 ? 'first' : 'second'; +} + +export function swapFirstAndLast(arr: T[]): T[] { + if (arr.length < 2) { + throw new Error('Array must have at least two elements to swap.'); + } + + // Swap the first and last elements using destructuring + // eslint-disable-next-line no-param-reassign + [arr[0], arr[arr.length - 1]] = [arr[arr.length - 1], arr[0]]; + + return arr; +}