diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d066c9c7..84de2c11 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -87,7 +87,7 @@ repos: # Lint / autoformat: Python code - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: "v0.6.4" + rev: "v0.6.7" hooks: # Run the linter - id: ruff diff --git a/src/backend/app/config.py b/src/backend/app/config.py index 6144ceb9..e795971b 100644 --- a/src/backend/app/config.py +++ b/src/backend/app/config.py @@ -105,6 +105,8 @@ def assemble_db_connection(cls, v: Optional[str], info: ValidationInfo) -> Any: EMAILS_FROM_EMAIL: Optional[EmailStr] = None EMAILS_FROM_NAME: Optional[str] = "Drone Tasking Manager" + NODE_ODM_URL: Optional[str] = "http://odm-api:3000" + @computed_field @property def emails_enabled(self) -> bool: diff --git a/src/backend/app/db/db_models.py b/src/backend/app/db/db_models.py index 22fda2cd..ac2ddb9a 100644 --- a/src/backend/app/db/db_models.py +++ b/src/backend/app/db/db_models.py @@ -74,6 +74,9 @@ class DbTask(Base): ) project_task_index = cast(int, Column(Integer)) outline = cast(WKBElement, Column(Geometry("POLYGON", srid=4326))) + take_off_point = cast( + WKBElement, Column(Geometry("POINT", srid=4326), nullable=True) + ) class DbProject(Base): diff --git a/src/backend/app/drones/drone_schemas.py b/src/backend/app/drones/drone_schemas.py index 0abaec2e..dfc3d1b9 100644 --- a/src/backend/app/drones/drone_schemas.py +++ b/src/backend/app/drones/drone_schemas.py @@ -35,7 +35,6 @@ class DbDrone(BaseDrone): @staticmethod async def one(db: Connection, drone_id: int): """Get a single drone by it's ID""" - print("drone_id = ", drone_id) async with db.cursor(row_factory=class_row(DbDrone)) as cur: await cur.execute( """ diff --git a/src/backend/app/migrations/versions/aec0d408df01_added_take_off_point_in_task.py b/src/backend/app/migrations/versions/aec0d408df01_added_take_off_point_in_task.py new file mode 100644 index 00000000..83d07d3c --- /dev/null +++ b/src/backend/app/migrations/versions/aec0d408df01_added_take_off_point_in_task.py @@ -0,0 +1,43 @@ +"""added take_off_point in task + +Revision ID: aec0d408df01 +Revises: 2b92f8a9bbec +Create Date: 2024-09-24 03:57:03.760365 + +""" + +import geoalchemy2 +import sqlalchemy as sa +from alembic import op +from typing import Sequence, Union + + +# revision identifiers, used by Alembic. +revision: str = "aec0d408df01" +down_revision: Union[str, None] = "2b92f8a9bbec" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "tasks", + sa.Column( + "take_off_point", + geoalchemy2.types.Geometry( + geometry_type="POINT", + srid=4326, + from_text="ST_GeomFromEWKT", + name="geometry", + ), + nullable=True, + ), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("tasks", "take_off_point") + # ### end Alembic commands ### diff --git a/src/backend/app/projects/image_processing.py b/src/backend/app/projects/image_processing.py new file mode 100644 index 00000000..09215ec8 --- /dev/null +++ b/src/backend/app/projects/image_processing.py @@ -0,0 +1,155 @@ +import uuid +import tempfile +import shutil +from pathlib import Path +from pyodm import Node +from app.s3 import get_file_from_bucket, list_objects_from_bucket, add_file_to_bucket +from loguru import logger as log +from concurrent.futures import ThreadPoolExecutor + + +class DroneImageProcessor: + def __init__( + self, + node_odm_url: str, + project_id: uuid.UUID, + task_id: uuid.UUID, + ): + """ + Initializes the connection to the ODM node. + """ + # self.node = Node(node_odm_host, node_odm_port) + self.node = Node.from_url(node_odm_url) + self.project_id = project_id + self.task_id = task_id + + def options_list_to_dict(self, options=[]): + """ + Converts options formatted as a list ([{'name': optionName, 'value': optionValue}, ...]) + to a dictionary {optionName: optionValue, ...} + """ + opts = {} + if options is not None: + for o in options: + opts[o["name"]] = o["value"] + return opts + + def download_object(self, bucket_name: str, obj, images_folder: str): + if obj.object_name.endswith((".jpg", ".jpeg", ".JPG", ".png", ".PNG")): + local_path = Path(images_folder) / Path(obj.object_name).name + local_path.parent.mkdir(parents=True, exist_ok=True) + get_file_from_bucket(bucket_name, obj.object_name, local_path) + + def download_images_from_s3(self, bucket_name, local_dir): + """ + Downloads images from MinIO under the specified path. + + :param bucket_name: Name of the MinIO bucket. + :param project_id: The project UUID. + :param task_id: The task UUID. + :param local_dir: Local directory to save the images. + :return: List of local image file paths. + """ + prefix = f"projects/{self.project_id}/{self.task_id}" + + objects = list_objects_from_bucket(bucket_name, prefix) + + # Process images concurrently + with ThreadPoolExecutor() as executor: + executor.map( + lambda obj: self.download_object(bucket_name, obj, local_dir), + objects, + ) + + def list_images(self, directory): + """ + Lists all images in the specified directory. + + :param directory: The directory containing the images. + :return: List of image file paths. + """ + images = [] + path = Path(directory) + + for file in path.rglob("*"): + if file.suffix.lower() in {".jpg", ".jpeg", ".png"}: + images.append(str(file)) + return images + + def process_new_task(self, images, name=None, options=[], progress_callback=None): + """ + Sends a set of images via the API to start processing. + + :param images: List of image file paths. + :param name: Name of the task. + :param options: Processing options ([{'name': optionName, 'value': optionValue}, ...]). + :param progress_callback: Callback function to report upload progress. + :return: The created task object. + """ + opts = self.options_list_to_dict(options) + + # FIXME: take this from the function above + opts = {"dsm": True} + + task = self.node.create_task(images, opts, name, progress_callback) + return task + + def monitor_task(self, task): + """ + Monitors the task progress until completion. + + :param task: The task object. + """ + log.info(f"Monitoring task {task.uuid}...") + task.wait_for_completion(interval=5) + log.info("Task completed.") + return task + + def download_results(self, task, output_path): + """ + Downloads all results of the task to the specified output path. + + :param task: The task object. + :param output_path: The directory where results will be saved. + """ + log.info(f"Downloading results to {output_path}...") + path = task.download_zip(output_path) + log.info("Download completed.") + return path + + def process_images_from_s3(self, bucket_name, name=None, options=[]): + """ + Processes images from MinIO storage. + + :param bucket_name: Name of the MinIO bucket. + :param project_id: The project UUID. + :param task_id: The task UUID. + :param name: Name of the task. + :param options: Processing options ([{'name': optionName, 'value': optionValue}, ...]). + :return: The task object. + """ + # Create a temporary directory to store downloaded images + temp_dir = tempfile.mkdtemp() + try: + self.download_images_from_s3(bucket_name, temp_dir) + + images_list = self.list_images(temp_dir) + + # Start a new processing task + task = self.process_new_task(images_list, name=name, options=options) + # Monitor task progress + self.monitor_task(task) + + # Optionally, download results + output_file_path = f"/tmp/{self.project_id}" + path_to_download = self.download_results(task, output_path=output_file_path) + + # Upload the results into s3 + s3_path = f"projects/{self.project_id}/{self.task_id}/assets.zip" + add_file_to_bucket(bucket_name, path_to_download, s3_path) + return task + + finally: + # Clean up temporary directory + shutil.rmtree(temp_dir) + pass diff --git a/src/backend/app/projects/project_logic.py b/src/backend/app/projects/project_logic.py index 81bc0e6b..01a36390 100644 --- a/src/backend/app/projects/project_logic.py +++ b/src/backend/app/projects/project_logic.py @@ -2,15 +2,23 @@ import uuid from loguru import logger as log from fastapi import HTTPException, UploadFile -from app.tasks.splitter import split_by_square +from app.tasks.task_splitter import split_by_square from fastapi.concurrency import run_in_threadpool from psycopg import Connection from app.utils import merge_multipolygon import shapely.wkb as wkblib from shapely.geometry import shape from io import BytesIO -from app.s3 import add_obj_to_bucket +from app.s3 import ( + add_obj_to_bucket, + list_objects_from_bucket, + get_presigned_url, + get_object_metadata, +) from app.config import settings +from app.projects.image_processing import DroneImageProcessor +from app.projects import project_schemas +from minio import S3Error async def upload_file_to_s3( @@ -155,3 +163,68 @@ async def preview_split_by_square(boundary: str, meters: int): meters=meters, ) ) + + +def process_drone_images(project_id: uuid.UUID, task_id: uuid.UUID): + # Initialize the processor + processor = DroneImageProcessor(settings.NODE_ODM_URL, project_id, task_id) + + # Define processing options + options = [ + {"name": "dsm", "value": True}, + {"name": "orthophoto-resolution", "value": 5}, + ] + + processor.process_images_from_s3( + settings.S3_BUCKET_NAME, name=f"DTM-Task-{task_id}", options=options + ) + + +async def get_project_info_from_s3(project_id: uuid.UUID, task_id: uuid.UUID): + """ + Helper function to get the number of images and the URL to download the assets. + """ + try: + # Prefix for the images + images_prefix = f"projects/{project_id}/{task_id}/images/" + + # List and count the images + objects = list_objects_from_bucket( + settings.S3_BUCKET_NAME, prefix=images_prefix + ) + image_extensions = (".jpg", ".jpeg", ".png", ".tif", ".tiff") + image_count = sum( + 1 for obj in objects if obj.object_name.lower().endswith(image_extensions) + ) + + # Generate a presigned URL for the assets ZIP file + try: + # Check if the object exists + assets_path = f"projects/{project_id}/{task_id}/assets.zip" + get_object_metadata(settings.S3_BUCKET_NAME, assets_path) + + # If it exists, generate the presigned URL + presigned_url = get_presigned_url( + settings.S3_BUCKET_NAME, assets_path, expires=2 + ) + except S3Error as e: + if e.code == "NoSuchKey": + # The object does not exist + log.info( + f"Assets ZIP file not found for project {project_id}, task {task_id}." + ) + presigned_url = None + else: + # An unexpected error occurred + log.error(f"An error occurred while accessing assets file: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + return project_schemas.AssetsInfo( + project_id=str(project_id), + task_id=str(task_id), + image_count=image_count, + assets_url=presigned_url, + ) + except Exception as e: + log.exception(f"An error occurred while retrieving assets info: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index 29c70f12..6a5b9bd0 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -1,4 +1,5 @@ import os +import uuid from typing import Annotated, Optional from uuid import UUID import geojson @@ -13,6 +14,7 @@ File, Form, Response, + BackgroundTasks, ) from geojson_pydantic import FeatureCollection from loguru import logger as log @@ -28,6 +30,7 @@ from app.users.user_schemas import AuthUser from app.tasks import task_schemas + router = APIRouter( prefix=f"{settings.API_PREFIX}/projects", responses={404: {"description": "Not found"}}, @@ -295,3 +298,33 @@ async def read_project( ): """Get a specific project and all associated tasks by ID.""" return project + + +@router.post("/process_imagery/{project_id}/{task_id}/", tags=["Image Processing"]) +async def process_imagery( + task_id: uuid.UUID, + project: Annotated[ + project_schemas.DbProject, Depends(project_deps.get_project_by_id) + ], + user_data: Annotated[AuthUser, Depends(login_required)], + background_tasks: BackgroundTasks, +): + background_tasks.add_task(project_logic.process_drone_images, project.id, task_id) + return {"message": "Processing started"} + + +@router.get( + "/assets/{project_id}/{task_id}/", + tags=["Image Processing"], + response_model=project_schemas.AssetsInfo, +) +async def get_assets_info( + project: Annotated[ + project_schemas.DbProject, Depends(project_deps.get_project_by_id) + ], + task_id: uuid.UUID, +): + """ + Endpoint to get the number of images and the URL to download the assets for a given project and task. + """ + return await project_logic.get_project_info_from_s3(project.id, task_id) diff --git a/src/backend/app/projects/project_schemas.py b/src/backend/app/projects/project_schemas.py index c49f746d..be52d3c7 100644 --- a/src/backend/app/projects/project_schemas.py +++ b/src/backend/app/projects/project_schemas.py @@ -457,3 +457,10 @@ class PresignedUrlRequest(BaseModel): task_id: uuid.UUID image_name: List[str] expiry: int # Expiry time in hours + + +class AssetsInfo(BaseModel): + project_id: str + task_id: str + image_count: int + assets_url: Optional[str] diff --git a/src/backend/app/s3.py b/src/backend/app/s3.py index 39a55a90..7120ccba 100644 --- a/src/backend/app/s3.py +++ b/src/backend/app/s3.py @@ -3,6 +3,7 @@ from minio import Minio from io import BytesIO from typing import Any +from datetime import timedelta def s3_client(): @@ -52,7 +53,7 @@ def add_file_to_bucket(bucket_name: str, file_path: str, s3_path: str): s3_path = f"/{s3_path}" client = s3_client() - client.fput_object(bucket_name, file_path, s3_path) + client.fput_object(bucket_name, s3_path, file_path) def add_obj_to_bucket( @@ -167,3 +168,48 @@ def get_image_dir_url(bucket_name: str, image_dir: str): except Exception as e: log.error(f"Error checking directory existence: {str(e)}") + + +def list_objects_from_bucket(bucket_name: str, prefix: str): + """List all objects in a bucket with a specified prefix. + Args: + bucket_name (str): The name of the S3 bucket. + prefix (str): The prefix to filter objects by. + Returns: + list: A list of objects in the bucket with the specified prefix. + """ + client = s3_client() + objects = client.list_objects(bucket_name, prefix=prefix, recursive=True) + return objects + + +def get_presigned_url(bucket_name: str, object_name: str, expires: int = 2): + """Generate a presigned URL for an object in an S3 bucket. + + Args: + bucket_name (str): The name of the S3 bucket. + object_name (str): The name of the object in the bucket. + expires (int, optional): The time in hours until the URL expires. + Defaults to 2 hour. + + Returns: + str: The presigned URL to access the object. + """ + client = s3_client() + return client.presigned_get_object( + bucket_name, object_name, expires=timedelta(hours=expires) + ) + + +def get_object_metadata(bucket_name: str, object_name: str): + """Get object metadata from an S3 bucket. + + Args: + bucket_name (str): The name of the S3 bucket. + object_name (str): The name of the object in the bucket. + + Returns: + dict: A dictionary containing metadata about the object. + """ + client = s3_client() + return client.stat_object(bucket_name, object_name) diff --git a/src/backend/app/tasks/task_logic.py b/src/backend/app/tasks/task_logic.py index c44c11b0..e8bff3da 100644 --- a/src/backend/app/tasks/task_logic.py +++ b/src/backend/app/tasks/task_logic.py @@ -1,10 +1,55 @@ -from psycopg import Connection import uuid +import json +from psycopg import Connection from app.models.enums import HTTPStatus, State from fastapi import HTTPException from psycopg.rows import dict_row +async def update_take_off_point_in_db( + db: Connection, task_id: uuid.UUID, take_off_point: str +): + """Update take_off_point in the task table""" + + async with db.cursor() as cur: + await cur.execute( + """ + UPDATE tasks + SET take_off_point = ST_SetSRID(ST_GeomFromGeoJSON(%(take_off_point)s), 4326) + WHERE id = %(task_id)s; + """, + { + "task_id": str(task_id), + "take_off_point": json.dumps(take_off_point), + }, + ) + + +async def get_take_off_point_from_db(db: Connection, task_id: uuid.UUID): + """Get take_off_point from task table""" + + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute( + """ + SELECT ST_AsGeoJSON(take_off_point) as take_off_point + FROM tasks + WHERE id = %(task_id)s; + """, + {"task_id": str(task_id)}, + ) + + data = await cur.fetchone() + if data is None: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Task not found" + ) + return ( + json.loads(data["take_off_point"]) + if data.get("take_off_point") is not None + else None + ) + + async def get_task_geojson(db: Connection, task_id: uuid.UUID): async with db.cursor() as cur: await cur.execute( diff --git a/src/backend/app/tasks/splitter.py b/src/backend/app/tasks/task_splitter.py similarity index 95% rename from src/backend/app/tasks/splitter.py rename to src/backend/app/tasks/task_splitter.py index 38d0ca78..27279053 100644 --- a/src/backend/app/tasks/splitter.py +++ b/src/backend/app/tasks/task_splitter.py @@ -119,15 +119,13 @@ def geojson_to_shapely_polygon( return shape(features[0].get("geometry")) def splitBySquare(self, meters: int) -> FeatureCollection: - # Define bounds of the area of interest (AOI) xmin, ymin, xmax, ymax = self.aoi.bounds - # Convert meters to degrees (assuming near-equator for simplicity) + meter = 0.0000114 length = float(meters) * meter width = float(meters) * meter - # Calculate area threshold for distinguishing large and small polygons - area_threshold = (length * width) / 4 + area_threshold = (length * width) / 3 # Generate grid columns and rows based on AOI bounds cols = np.arange(xmin, xmax + width, width) @@ -158,8 +156,11 @@ def splitBySquare(self, meters: int) -> FeatureCollection: if small_polygon.touches(large_polygon) ] if adjacent_polygons: - # Get the adjacent polygon with the minimum area - nearest_polygon = min(adjacent_polygons, key=lambda p: p.area) + # Get the adjacent polygon with the maximum shared boundary length + nearest_polygon = max( + adjacent_polygons, + key=lambda p: small_polygon.intersection(p).length, + ) # Merge the small polygon with the nearest large polygon merged_polygon = unary_union([small_polygon, nearest_polygon]) diff --git a/src/backend/app/waypoints/waypoint_routes.py b/src/backend/app/waypoints/waypoint_routes.py index af795fb2..b87399af 100644 --- a/src/backend/app/waypoints/waypoint_routes.py +++ b/src/backend/app/waypoints/waypoint_routes.py @@ -13,7 +13,11 @@ waypoints, ) from app.models.enums import HTTPStatus -from app.tasks.task_logic import get_task_geojson +from app.tasks.task_logic import ( + get_task_geojson, + get_take_off_point_from_db, + update_take_off_point_in_db, +) from app.waypoints.waypoint_logic import check_point_within_buffer from app.db import database from app.utils import merge_multipolygon @@ -21,9 +25,8 @@ from typing import Annotated from psycopg import Connection from app.projects import project_deps -from geojson_pydantic import Point 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 @@ -41,7 +44,7 @@ async def get_task_waypoint( project_id: uuid.UUID, task_id: uuid.UUID, download: bool = True, - take_off_point: Point = None, + take_off_point: waypoint_schemas.PointField = None, ): """ Retrieve task waypoints and download a flight plan. @@ -62,16 +65,29 @@ async def get_task_waypoint( # create a takeoff point in this format ["lon","lat"] if take_off_point: take_off_point = [take_off_point.longitude, take_off_point.latitude] + + # Validate that the take-off point is within a 200m buffer of the task boundary if not check_point_within_buffer(take_off_point, task_geojson, 200): raise HTTPException( status_code=400, detail="Take off point should be within 200m of the boundary", ) + + # Update take_off_point in tasks table + geojson_point = {"type": "Point", "coordinates": take_off_point} + await update_take_off_point_in_db(db, task_id, geojson_point) + else: - # take the centroid of the task as the takeoff point - task_polygon = shape(task_geojson["features"][0]["geometry"]) - task_centroid = task_polygon.centroid - take_off_point = [task_centroid.x, task_centroid.y] + # Retrieve the take-off point from the database if not explicitly provided + take_off_point_from_db = await get_take_off_point_from_db(db, task_id) + + if take_off_point_from_db: + take_off_point = take_off_point_from_db["coordinates"] + else: + # Use the centroid of the task polygon as the default take-off point + task_polygon = shape(task_geojson["features"][0]["geometry"]) + task_centroid = task_polygon.centroid + take_off_point = [task_centroid.x, task_centroid.y] forward_overlap = project.front_overlap if project.front_overlap else 70 side_overlap = project.side_overlap if project.side_overlap else 70 @@ -105,16 +121,19 @@ async def get_task_waypoint( if project.is_terrain_follow: dem_path = f"/tmp/{uuid.uuid4()}/dem.tif" - get_file_from_bucket( - settings.S3_BUCKET_NAME, f"projects/{project_id}/dem.tif", dem_path - ) + try: + get_file_from_bucket( + settings.S3_BUCKET_NAME, f"projects/{project_id}/dem.tif", dem_path + ) + # TODO: Do this with inmemory data + outfile_with_elevation = "/tmp/output_file_with_elevation.geojson" + add_elevation_from_dem(dem_path, points, outfile_with_elevation) - # TODO: Do this with inmemory data - outfile_with_elevation = "/tmp/output_file_with_elevation.geojson" - add_elevation_from_dem(dem_path, points, outfile_with_elevation) + inpointsfile = open(outfile_with_elevation, "r") + points_with_elevation = inpointsfile.read() - inpointsfile = open(outfile_with_elevation, "r") - points_with_elevation = inpointsfile.read() + except Exception: + points_with_elevation = points placemarks = create_placemarks(geojson.loads(points_with_elevation), parameters) else: @@ -171,7 +190,7 @@ async def generate_kmz( None, description="The Digital Elevation Model (DEM) file that will be used to generate the terrain follow flight plan. This file should be in GeoTIFF format", ), - take_off_point: Point = None, + take_off_point: waypoint_schemas.PointField = None, ): if not (altitude or gsd): raise HTTPException( diff --git a/src/backend/app/waypoints/waypoint_schemas.py b/src/backend/app/waypoints/waypoint_schemas.py index 32e4fee0..e20fd87c 100644 --- a/src/backend/app/waypoints/waypoint_schemas.py +++ b/src/backend/app/waypoints/waypoint_schemas.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, model_validator -class Point(BaseModel): +class PointField(BaseModel): longitude: float latitude: float diff --git a/src/backend/pdm.lock b/src/backend/pdm.lock index b031501c..96339e9a 100644 --- a/src/backend/pdm.lock +++ b/src/backend/pdm.lock @@ -363,6 +363,16 @@ version = "2.9.0" requires_python = ">=3.8" summary = "JSON Web Token implementation in Python" +[[package]] +name = "pyodm" +version = "1.5.11" +summary = "Python SDK for OpenDroneMap" +dependencies = [ + "requests", + "requests-toolbelt", + "urllib3", +] + [[package]] name = "pyproj" version = "3.6.1" @@ -415,6 +425,15 @@ dependencies = [ "requests>=2.0.0", ] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +summary = "A utility belt for advanced users of python-requests" +dependencies = [ + "requests<3.0.0,>=2.0.1", +] + [[package]] name = "shapely" version = "2.0.5" @@ -492,7 +511,7 @@ summary = "A small Python utility to set file creation time on Windows" lock_version = "4.2" cross_platform = true groups = ["default"] -content_hash = "sha256:7c804d9b1dbeb05a25f788f88ea8652b2fee18316cd160c81ed67840aecdf6f2" +content_hash = "sha256:34e737b7b5f07d56925b5b8892bcdee256dfe9cec5d8986c30772ecccb70e028" [metadata.files] "aiosmtplib 3.0.2" = [ @@ -1143,6 +1162,10 @@ content_hash = "sha256:7c804d9b1dbeb05a25f788f88ea8652b2fee18316cd160c81ed67840a {url = "https://files.pythonhosted.org/packages/79/84/0fdf9b18ba31d69877bd39c9cd6052b47f3761e9910c15de788e519f079f/PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"}, {url = "https://files.pythonhosted.org/packages/fb/68/ce067f09fca4abeca8771fe667d89cc347d1e99da3e093112ac329c6020e/pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"}, ] +"pyodm 1.5.11" = [ + {url = "https://files.pythonhosted.org/packages/41/d8/186ee7b2a95f7b8e50f2068e152f5b3a58d838fea90eabde0785c79f32c5/pyodm-1.5.11-py3-none-any.whl", hash = "sha256:c0b9a4358db12f2a84a4d13533b9a3ea4c63419d6518420ba7a2ab32842294b8"}, + {url = "https://files.pythonhosted.org/packages/52/99/53e514b55916ef91c2b6f8f611c0e03465caa9e5a17c742056b543f0c8ee/pyodm-1.5.11.tar.gz", hash = "sha256:bb97710171bfaee92e145bd97b1ac77db8beef580b89d6585a622ff6fb424c53"}, +] "pyproj 3.6.1" = [ {url = "https://files.pythonhosted.org/packages/0b/64/93232511a7906a492b1b7dfdfc17f4e95982d76a24ef4f86d18cfe7ae2c9/pyproj-3.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:1e9fbaf920f0f9b4ee62aab832be3ae3968f33f24e2e3f7fbb8c6728ef1d9746"}, {url = "https://files.pythonhosted.org/packages/0e/ab/1c2159ec757677c5a6b8803f6be45c2b550dc42c84ec4a228dc219849bbb/pyproj-3.6.1-cp312-cp312-win32.whl", hash = "sha256:2d6ff73cc6dbbce3766b6c0bce70ce070193105d8de17aa2470009463682a8eb"}, @@ -1192,6 +1215,10 @@ content_hash = "sha256:7c804d9b1dbeb05a25f788f88ea8652b2fee18316cd160c81ed67840a {url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, {url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, ] +"requests-toolbelt 1.0.0" = [ + {url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, + {url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, +] "shapely 2.0.5" = [ {url = "https://files.pythonhosted.org/packages/04/df/8062f14cb7aa502b8bda358103facedc80b87eec41e3391182655ff40615/shapely-2.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:03bd7b5fa5deb44795cc0a503999d10ae9d8a22df54ae8d4a4cd2e8a93466195"}, {url = "https://files.pythonhosted.org/packages/13/56/11150c625bc984e9395913a255f52cf6c7de85b98396339cee66119481d4/shapely-2.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8af6f7260f809c0862741ad08b1b89cb60c130ae30efab62320bbf4ee9cc71fa"}, diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index b3f5e487..518303c3 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "python-slugify>=8.0.4", "drone-flightplan==0.3.1rc4", "psycopg2>=2.9.9", + "pyodm>=1.5.11", ] requires-python = ">=3.11" license = {text = "GPL-3.0-only"} diff --git a/src/frontend/package.json b/src/frontend/package.json index 613f7b14..f07f9e6a 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -15,6 +15,7 @@ "@turf/area": "^7.0.0", "@turf/bbox": "^7.0.0", "@turf/centroid": "^7.0.0", + "@turf/helpers": "^7.0.0", "@turf/meta": "^7.0.0", "@turf/flatten": "^7.0.0", "@turf/length": "^7.0.0", diff --git a/src/frontend/src/api/tasks.ts b/src/frontend/src/api/tasks.ts index 9cd46ba3..46a31e45 100644 --- a/src/frontend/src/api/tasks.ts +++ b/src/frontend/src/api/tasks.ts @@ -1,5 +1,9 @@ /* eslint-disable import/prefer-default-export */ -import { getIndividualTask, getTaskWaypoint } from '@Services/tasks'; +import { + getIndividualTask, + getTaskAssetsInfo, + getTaskWaypoint, +} from '@Services/tasks'; import { useQuery, UseQueryOptions } from '@tanstack/react-query'; export const useGetTaskWaypointQuery = ( @@ -28,3 +32,17 @@ export const useGetIndividualTaskQuery = ( ...queryOptions, }); }; + +export const useGetTaskAssetsInfo = ( + projectId: string, + taskId: string, + queryOptions?: Partial, +) => { + return useQuery({ + queryKey: ['task-assets-info'], + enabled: !!taskId, + queryFn: () => getTaskAssetsInfo(projectId, taskId), + select: (res: any) => res.data, + ...queryOptions, + }); +}; diff --git a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DescriptionBox/index.tsx b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DescriptionBox/index.tsx index 50fc143d..e62a08ea 100644 --- a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DescriptionBox/index.tsx +++ b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DescriptionBox/index.tsx @@ -1,10 +1,15 @@ import { useParams } from 'react-router-dom'; -import { useGetIndividualTaskQuery, useGetTaskWaypointQuery } from '@Api/tasks'; +import { + useGetIndividualTaskQuery, + useGetTaskAssetsInfo, + useGetTaskWaypointQuery, +} from '@Api/tasks'; import { useState } from 'react'; // import { useTypedSelector } from '@Store/hooks'; import { format } from 'date-fns'; import DescriptionBoxComponent from './DescriptionComponent'; import QuestionBox from '../QuestionBox'; +import UploadsInformation from '../UploadsInformation'; const DescriptionBox = () => { // const secondPageStates = useTypedSelector(state => state.droneOperatorTask); @@ -21,6 +26,8 @@ const DescriptionBox = () => { }, }, ); + const { data: taskAssetsInformation }: Record = + useGetTaskAssetsInfo(projectId as string, taskId as string); const { data: taskDescription }: Record = useGetIndividualTaskQuery(taskId as string, { @@ -103,7 +110,28 @@ const DescriptionBox = () => { ))} {/* {!secondPage && } */} - + + + {taskAssetsInformation?.image_count > 0 && ( +
+ +
+ )} ); }; diff --git a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/PopoverBox/ImageBox/ImageCard/index.tsx b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/PopoverBox/ImageBox/ImageCard/index.tsx index c9bb0557..fb23e168 100644 --- a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/PopoverBox/ImageBox/ImageCard/index.tsx +++ b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/PopoverBox/ImageBox/ImageCard/index.tsx @@ -21,7 +21,7 @@ const ImageCard = ({ return ( <>
dispatch(setSelectedImage(image))} > diff --git a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/PopoverBox/ImageBox/index.tsx b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/PopoverBox/ImageBox/index.tsx index 9c5bf842..6b0bf2b7 100644 --- a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/PopoverBox/ImageBox/index.tsx +++ b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/PopoverBox/ImageBox/index.tsx @@ -3,27 +3,24 @@ /* eslint-disable no-await-in-loop */ /* eslint-disable no-console */ /* eslint-disable no-unused-vars */ -import { useEffect, useState, useRef } from 'react'; -import { motion } from 'framer-motion'; -import { useParams } from 'react-router-dom'; import { useMutation } from '@tanstack/react-query'; +import { motion } from 'framer-motion'; +import { useEffect, useRef, useState } from 'react'; -import { useTypedDispatch, useTypedSelector } from '@Store/hooks'; +import { Button } from '@Components/RadixComponents/Button'; +import { getImageUploadLink } from '@Services/droneOperator'; import { + checkAllImages, setCheckedImages, - showPopover, unCheckAllImages, - checkAllImages, } from '@Store/actions/droneOperatorTask'; -import Icon from '@Components/common/Icon'; -import { Button } from '@Components/RadixComponents/Button'; -import { getImageUploadLink } from '@Services/droneOperator'; -import delay from '@Utils/createDelay'; -import chunkArray from '@Utils/createChunksOfArray'; +import { useTypedDispatch, useTypedSelector } from '@Store/hooks'; import callApiSimultaneously from '@Utils/callApiSimultaneously'; +import chunkArray from '@Utils/createChunksOfArray'; +import delay from '@Utils/createDelay'; import widthCalulator from '@Utils/percentageCalculator'; -import ImageCard from './ImageCard'; import FilesUploadingPopOver from '../LoadingBox'; +import ImageCard from './ImageCard'; import PreviewImage from './PreviewImage'; // interface IImageBoxPopOverProps { @@ -151,7 +148,7 @@ const ImageBoxPopOver = () => { className={`naxatw-grid naxatw-gap-4 ${clickedImage ? 'naxatw-grid-cols-[70%_auto]' : 'naxatw-grid-cols-1'}`} >
{imageObject?.map((image, index) => ( { - const navigate = useNavigate(); + const dispatch = useDispatch(); + // const navigate = useNavigate(); - // function to redirect to dashboard after 2 seconds - function redirectToDashboard() { + // function to close modal + function closeModal() { setTimeout(() => { - navigate('/dashboard'); + // navigate('/dashboard'); + dispatch(toggleModal()); }, 2000); return null; } @@ -49,8 +52,8 @@ const FilesUploadingPopOver = ({

{uploadedFiles === filesLength && uploadedFiles !== 0 ? ( <> -

Redirecting to Dashboard ...

- {redirectToDashboard()} + {/*

Redirecting to Dashboard ...

*/} + {closeModal()} ) : ( `${uploadedFiles} / ${filesLength} Files Uploaded` diff --git a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/QuestionBox/index.tsx b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/QuestionBox/index.tsx index 7189aa1d..0eb87ec2 100644 --- a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/QuestionBox/index.tsx +++ b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/QuestionBox/index.tsx @@ -12,9 +12,14 @@ import UploadsBox from '../UploadsBox'; interface IQuestionBoxProps { flyable: string; setFlyable: React.Dispatch>; + haveNoImages: boolean; } -const QuestionBox = ({ flyable, setFlyable }: IQuestionBoxProps) => { +const QuestionBox = ({ + flyable, + setFlyable, + haveNoImages, +}: IQuestionBoxProps) => { const { projectId, taskId } = useParams(); const dispatch = useTypedDispatch(); @@ -119,7 +124,7 @@ const QuestionBox = ({ flyable, setFlyable }: IQuestionBoxProps) => {
- {flyable === 'yes' && } + {flyable === 'yes' && haveNoImages && }
); diff --git a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/UploadsBox/index.tsx b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/UploadsBox/index.tsx index a57e1718..4df0ea89 100644 --- a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/UploadsBox/index.tsx +++ b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/UploadsBox/index.tsx @@ -3,7 +3,7 @@ /* eslint-disable no-unused-vars */ import { Button } from '@Components/RadixComponents/Button'; import { toggleModal } from '@Store/actions/common'; -import { setFiles, showPopover } from '@Store/actions/droneOperatorTask'; +import { setFiles } from '@Store/actions/droneOperatorTask'; import { useTypedDispatch, useTypedSelector } from '@Store/hooks'; const UploadsBox = () => { diff --git a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/UploadsInformation/index.tsx b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/UploadsInformation/index.tsx new file mode 100644 index 00000000..0a86a0e5 --- /dev/null +++ b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/UploadsInformation/index.tsx @@ -0,0 +1,29 @@ +const UploadsInformation = ({ data }: { data: Record[] }) => { + return ( + <> +
+
+

+ Upload Information +

+
+ + {data.map(information => ( +
+

+ {information?.name} +

+

:

+

+ {information?.value} +

+
+ ))} +
+ + ); +}; +export default UploadsInformation; diff --git a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/index.tsx b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/index.tsx index 0120f319..f49d37d3 100644 --- a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/index.tsx +++ b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/index.tsx @@ -1,16 +1,16 @@ /* eslint-disable no-nested-ternary */ -import { useState, useEffect } from 'react'; -import { useParams } from 'react-router-dom'; -import { motion } from 'framer-motion'; -import { Button } from '@Components/RadixComponents/Button'; -import Tab from '@Components/common/Tabs'; import { useGetIndividualTaskQuery, useGetTaskWaypointQuery } from '@Api/tasks'; -import { useTypedDispatch, useTypedSelector } from '@Store/hooks'; -import { setSecondPageState } from '@Store/actions/droneOperatorTask'; +import { Button } from '@Components/RadixComponents/Button'; +import useWindowDimensions from '@Hooks/useWindowDimensions'; +import { useTypedSelector } from '@Store/hooks'; import hasErrorBoundary from '@Utils/hasErrorBoundary'; +import { motion } from 'framer-motion'; +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; import { toast } from 'react-toastify'; -import UploadsBox from './UploadsBox'; +import MapSection from '../MapSection'; import DescriptionBox from './DescriptionBox'; +import UploadsBox from './UploadsBox'; const { BASE_URL } = process.env; @@ -19,6 +19,9 @@ const DroneOperatorDescriptionBox = () => { const secondPageStates = useTypedSelector(state => state.droneOperatorTask); const { secondPageState, secondPage } = secondPageStates; const [animated, setAnimated] = useState(false); + const [showDownloadOptions, setShowDownloadOptions] = + useState(false); + const { width } = useWindowDimensions(); const { data: taskDescription }: Record = useGetIndividualTaskQuery(taskId as string); @@ -78,25 +81,11 @@ const DroneOperatorDescriptionBox = () => { ); } }; - const dispatch = useTypedDispatch(); - - const headerTabOptions = [ - { - id: 1, - label: 'Description', - value: 'description', - }, - { - id: 2, - label: 'Uploads', - value: 'uploads', - }, - ]; const handleDownloadFlightPlan = () => { fetch( `${BASE_URL}/waypoint/task/${taskId}/?project_id=${projectId}&download=true`, - {"method":'POST'} + { method: 'POST' }, ) .then(response => { if (!response.ok) { @@ -147,38 +136,48 @@ const DroneOperatorDescriptionBox = () => {

Task #{taskDescription?.project_task_index}

-
- + +
+ {showDownloadOptions && ( +
+
handleDownloadFlightPlan()} + onClick={() => { + handleDownloadFlightPlan(); + setShowDownloadOptions(false); + }} + > + Download flight plan +
+
downloadGeojson()} + onClick={() => { + downloadGeojson(); + setShowDownloadOptions(false); + }} + > + Download geojson +
+
+ )}
- { - dispatch(setSecondPageState(value)); - }} - tabOptions={headerTabOptions} - activeTab={secondPageState} - orientation="row" - className={`naxatw-h-[3rem] naxatw-border-b naxatw-bg-transparent hover:naxatw-border-b-2 hover:naxatw-border-red ${!secondPage ? 'naxatw-hidden' : 'naxatw-block'}`} - activeClassName="naxatw-border-b-2 naxatw-bg-transparent naxatw-border-red" - clickable - /> + {width < 640 && } {renderComponent(secondPageState)} diff --git a/src/frontend/src/components/DroneOperatorTask/MapSection/GetCoordinatesOnClick.tsx b/src/frontend/src/components/DroneOperatorTask/MapSection/GetCoordinatesOnClick.tsx new file mode 100644 index 00000000..66659ad5 --- /dev/null +++ b/src/frontend/src/components/DroneOperatorTask/MapSection/GetCoordinatesOnClick.tsx @@ -0,0 +1,31 @@ +/* eslint-disable no-param-reassign */ +import { MapInstanceType } from '@Components/common/MapLibreComponents/types'; +import { useEffect } from 'react'; + +interface IGetCoordinatesOnClick { + map?: MapInstanceType; + isMapLoaded?: Boolean; + getCoordinates: any; +} + +const GetCoordinatesOnClick = ({ + map, + isMapLoaded, + getCoordinates, +}: IGetCoordinatesOnClick) => { + useEffect(() => { + if (!map || !isMapLoaded) return () => {}; + map.getCanvas().style.cursor = 'crosshair'; + map.on('click', e => { + const latLng = e.lngLat; + getCoordinates(latLng); + }); + + return () => { + map.getCanvas().style.cursor = ''; + }; + }, [map, isMapLoaded, getCoordinates]); + return null; +}; + +export default GetCoordinatesOnClick; diff --git a/src/frontend/src/components/DroneOperatorTask/MapSection/ShowInfo.tsx b/src/frontend/src/components/DroneOperatorTask/MapSection/ShowInfo.tsx new file mode 100644 index 00000000..e16aa1a5 --- /dev/null +++ b/src/frontend/src/components/DroneOperatorTask/MapSection/ShowInfo.tsx @@ -0,0 +1,31 @@ +interface IShowInfo { + heading?: string; + message: string; + wrapperClassName?: string; + className?: string; +} + +const ShowInfo = ({ + message, + className, + heading, + wrapperClassName, +}: IShowInfo) => { + return ( +
+
+ info{' '} +
+ {heading} +
+
+
+ {message} +
+
+ ); +}; + +export default ShowInfo; diff --git a/src/frontend/src/components/DroneOperatorTask/MapSection/index.tsx b/src/frontend/src/components/DroneOperatorTask/MapSection/index.tsx index 5ddd3954..5666af81 100644 --- a/src/frontend/src/components/DroneOperatorTask/MapSection/index.tsx +++ b/src/frontend/src/components/DroneOperatorTask/MapSection/index.tsx @@ -1,24 +1,37 @@ /* eslint-disable react/no-array-index-key */ -import { useCallback, useEffect, useState } from 'react'; -import { LngLatBoundsLike, Map } from 'maplibre-gl'; -import { useParams } from 'react-router-dom'; -import { FeatureCollection } from 'geojson'; import { useGetTaskWaypointQuery } from '@Api/tasks'; -import getBbox from '@turf/bbox'; -import { coordAll } from '@turf/meta'; +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 right from '@Assets/images/rightArrow.png'; -import marker from '@Assets/images/marker.png'; +import { Button } from '@Components/RadixComponents/Button'; +import { postTaskWaypoint } from '@Services/tasks'; +import { toggleModal } from '@Store/actions/common'; +import { setSelectedTakeOffPoint } from '@Store/actions/droneOperatorTask'; +import { useTypedSelector } from '@Store/hooks'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import getBbox from '@turf/bbox'; +import { point } from '@turf/helpers'; +import { coordAll } from '@turf/meta'; import hasErrorBoundary from '@Utils/hasErrorBoundary'; -import AsyncPopup from '@Components/common/MapLibreComponents/AsyncPopup'; -import BaseLayerSwitcherUI from '@Components/common/BaseLayerSwitcher'; -import LocateUser from '@Components/common/MapLibreComponents/LocateUser'; +import { FeatureCollection } from 'geojson'; +import { LngLatBoundsLike, Map } from 'maplibre-gl'; +import { useCallback, useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { useParams } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import GetCoordinatesOnClick from './GetCoordinatesOnClick'; +import ShowInfo from './ShowInfo'; -const MapSection = () => { +const MapSection = ({ className }: { className?: string }) => { + const dispatch = useDispatch(); const { projectId, taskId } = useParams(); + const queryClient = useQueryClient(); const [popupData, setPopupData] = useState>({}); const { map, isMapLoaded } = useMapLibreGLMap({ containerId: 'dashboard-map', @@ -29,6 +42,9 @@ const MapSection = () => { }, disableRotation: true, }); + const newTakeOffPoint = useTypedSelector( + state => state.droneOperatorTask.selectedTakeOffPoint, + ); const { data: taskWayPoints }: any = useGetTaskWaypointQuery( projectId as string, @@ -56,13 +72,42 @@ const MapSection = () => { }, ); - // zoom to task + const { mutate: postWaypoint, isLoading: isUpdatingTakeOffPoint } = + useMutation({ + mutationFn: postTaskWaypoint, + onSuccess: async data => { + // update task cached waypoint data with response + queryClient.setQueryData(['task-waypoints'], () => { + return data; + }); + dispatch(setSelectedTakeOffPoint(null)); + }, + onError: (err: any) => { + toast.error(err?.response?.data?.detail || err.message); + }, + }); + + // zoom to task (waypoint) useEffect(() => { - if (!taskWayPoints?.geojsonAsLineString) return; + if (!taskWayPoints?.geojsonAsLineString || !isMapLoaded || !map) return; const { geojsonAsLineString } = taskWayPoints; - const bbox = getBbox(geojsonAsLineString as FeatureCollection); + let bbox = null; + // calculate bbox with with updated take-off point + if (newTakeOffPoint && newTakeOffPoint !== 'place_on_map') { + const combinedFeatures: FeatureCollection = { + type: 'FeatureCollection', + features: [ + ...geojsonAsLineString.features, + // @ts-ignore + newTakeOffPoint, + ], + }; + bbox = getBbox(combinedFeatures); + } else { + bbox = getBbox(geojsonAsLineString as FeatureCollection); + } map?.fitBounds(bbox as LngLatBoundsLike, { padding: 25, duration: 500 }); - }, [map, taskWayPoints]); + }, [map, taskWayPoints, newTakeOffPoint, isMapLoaded]); const getPopupUI = useCallback(() => { return ( @@ -70,15 +115,20 @@ const MapSection = () => {

{popupData?.index}

- {popupData?.coordinates?.lat?.toFixed(8)}, {popupData?.coordinates?.lng?.toFixed(8)}{' '} + {popupData?.coordinates?.lat?.toFixed(8)},  + {popupData?.coordinates?.lng?.toFixed(8)}{' '}

Speed: {popupData?.speed} m/s

- {popupData?.elevation && -

Elevation (Sea Level): {popupData?.elevation} meter

- } -

Take Photo: {popupData?.take_photo ? "True" : "False"}

+ {popupData?.elevation && ( +

+ Elevation (Sea Level): {popupData?.elevation} meter{' '} +

+ )} +

+ Take Photo: {popupData?.take_photo ? 'True' : 'False'} +

Gimble angle: {popupData?.gimbal_angle} degree

@@ -92,9 +142,31 @@ const MapSection = () => { ); }, [popupData]); + const handleSaveStartingPoint = () => { + const { geometry } = newTakeOffPoint as Record; + const [lng, lat] = geometry.coordinates; + postWaypoint({ + projectId, + taskId, + data: { + longitude: lng, + latitude: lat, + }, + }); + }; + + useEffect( + () => () => { + dispatch(setSelectedTakeOffPoint(null)); + }, + [dispatch], + ); + return ( <> -
+
{ )} +
+ +
+ + {newTakeOffPoint && ( + + )} + + {newTakeOffPoint === 'place_on_map' && ( + ) => + dispatch( + setSelectedTakeOffPoint( + point([coordinates.lng, coordinates?.lat]), + ), + ) + } + /> + )} + + {newTakeOffPoint === 'place_on_map' && ( + + )} + ) => diff --git a/src/frontend/src/components/DroneOperatorTask/ModalContent/ChooseTakeOffPointOptions.tsx b/src/frontend/src/components/DroneOperatorTask/ModalContent/ChooseTakeOffPointOptions.tsx new file mode 100644 index 00000000..1cb16b92 --- /dev/null +++ b/src/frontend/src/components/DroneOperatorTask/ModalContent/ChooseTakeOffPointOptions.tsx @@ -0,0 +1,61 @@ +import RadioButton from '@Components/common/RadioButton'; +import { Button } from '@Components/RadixComponents/Button'; +import { takeOffPointOptions } from '@Constants/taskDescription'; +import { toggleModal } from '@Store/actions/common'; +import { + setSelectedTakeOffPoint, + setSelectedTakeOffPointOption, +} from '@Store/actions/droneOperatorTask'; +import { useTypedDispatch, useTypedSelector } from '@Store/hooks'; +import { point } from '@turf/helpers'; + +const ChooseTakeOffPointOptions = () => { + const dispatch = useTypedDispatch(); + const selectedTakeOffPointOption = useTypedSelector( + state => state.droneOperatorTask.selectedTakeOffPointOption, + ); + + const handleNextClick = () => { + if (selectedTakeOffPointOption === 'current_location') { + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition(latLng => + dispatch( + setSelectedTakeOffPoint( + point([latLng?.coords?.longitude, latLng?.coords?.latitude]), + ), + ), + ); + } + } else { + dispatch(setSelectedTakeOffPoint(selectedTakeOffPointOption)); + } + dispatch(toggleModal()); + }; + return ( +
+

+ Please select the take-off point for your drone. +

+
+ dispatch(setSelectedTakeOffPointOption(value))} + value={selectedTakeOffPointOption} + /> +
+
+ +
+
+ ); +}; + +export default ChooseTakeOffPointOptions; diff --git a/src/frontend/src/constants/modalContents.tsx b/src/frontend/src/constants/modalContents.tsx index a5eb0a79..caecbacd 100644 --- a/src/frontend/src/constants/modalContents.tsx +++ b/src/frontend/src/constants/modalContents.tsx @@ -1,11 +1,13 @@ -import { ReactElement } from 'react'; import ExitCreateProjectModal from '@Components/CreateProject/ExitCreateProjectModal'; import ImageBoxPopOver from '@Components/DroneOperatorTask/DescriptionSection/PopoverBox/ImageBox'; +import ChooseTakeOffPointOptions from '@Components/DroneOperatorTask/ModalContent/ChooseTakeOffPointOptions'; +import { ReactElement } from 'react'; export type ModalContentsType = | 'sign-up-success' | 'quit-create-project' | 'raw-image-preview' + | 'update-flight-take-off-point' | null; export type PromptDialogContentsType = 'delete-layer' | null; @@ -31,10 +33,16 @@ export function getModalContent(content: ModalContentsType): ModalReturnType { case 'raw-image-preview': return { - className: '!naxatw-w-[60vw]', + className: '!naxatw-w-[95vw] md:!naxatw-w-[60vw]', title: 'Upload Raw Image', content: , }; + case 'update-flight-take-off-point': + return { + className: 'naxatw-w-[92vw] naxatw-max-w-[25rem]', + title: 'Take-off Point', + content: , + }; default: return { diff --git a/src/frontend/src/constants/taskDescription.ts b/src/frontend/src/constants/taskDescription.ts new file mode 100644 index 00000000..afeebfb4 --- /dev/null +++ b/src/frontend/src/constants/taskDescription.ts @@ -0,0 +1,14 @@ +/* eslint-disable import/prefer-default-export */ + +export const takeOffPointOptions = [ + { + label: 'My current location', + value: 'current_location', + name: 'take_off_point', + }, + { + label: 'Place on map', + value: 'place_on_map', + name: 'take_off_point', + }, +]; diff --git a/src/frontend/src/hooks/useWindowDimensions.tsx b/src/frontend/src/hooks/useWindowDimensions.tsx new file mode 100644 index 00000000..ee0a0de8 --- /dev/null +++ b/src/frontend/src/hooks/useWindowDimensions.tsx @@ -0,0 +1,36 @@ +import { useState, useEffect } from 'react'; + +function debounce(func: Function, timeout = 300) { + let timer: any; + return (arg: any) => { + clearTimeout(timer); + timer = setTimeout(() => { + func(arg); + }, timeout); + }; +} + +const useWindowDimensions = () => { + const [dimensions, setDimensions] = useState({ + width: window.innerWidth, + height: window.innerHeight, + }); + + useEffect(() => { + const handleResize = debounce(() => { + setDimensions({ + width: window.innerWidth, + height: window.innerHeight, + }); + }, 100); + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + return dimensions; +}; + +export default useWindowDimensions; diff --git a/src/frontend/src/services/tasks.ts b/src/frontend/src/services/tasks.ts index daa54213..47a7f4d1 100644 --- a/src/frontend/src/services/tasks.ts +++ b/src/frontend/src/services/tasks.ts @@ -7,3 +7,16 @@ export const getTaskWaypoint = (projectId: string, taskId: string) => export const getIndividualTask = (taskId: string) => authenticated(api).get(`/tasks/${taskId}`); + +export const postTaskWaypoint = (payload: Record) => { + const { taskId, projectId, data } = payload; + return authenticated(api).post( + `/waypoint/task/${taskId}/?project_id=${projectId}&download=false`, + data, + { + headers: { 'Content-Type': 'application/json' }, + }, + ); +}; +export const getTaskAssetsInfo = (projectId: string, taskId: string) => + authenticated(api).get(`/projects/assets/${projectId}/${taskId}/`); diff --git a/src/frontend/src/store/actions/droneOperatorTask.ts b/src/frontend/src/store/actions/droneOperatorTask.ts index 5564edd5..19e24a36 100644 --- a/src/frontend/src/store/actions/droneOperatorTask.ts +++ b/src/frontend/src/store/actions/droneOperatorTask.ts @@ -10,4 +10,6 @@ export const { unCheckAllImages, checkAllImages, setFiles, + setSelectedTakeOffPointOption, + setSelectedTakeOffPoint, } = droneOperatorTaskSlice.actions; diff --git a/src/frontend/src/store/slices/droneOperartorTask.ts b/src/frontend/src/store/slices/droneOperartorTask.ts index 5bb88bbc..85f33d4a 100644 --- a/src/frontend/src/store/slices/droneOperartorTask.ts +++ b/src/frontend/src/store/slices/droneOperartorTask.ts @@ -8,6 +8,8 @@ export interface IDroneOperatorTaskState { checkedImages: Record; popOver: boolean; files: any[]; + selectedTakeOffPointOption: string; + selectedTakeOffPoint: any[] | string | null; } const initialState: IDroneOperatorTaskState = { @@ -17,6 +19,8 @@ const initialState: IDroneOperatorTaskState = { checkedImages: {}, popOver: false, files: [], + selectedTakeOffPointOption: 'current_location', + selectedTakeOffPoint: null, }; export const droneOperatorTaskSlice = createSlice({ @@ -55,6 +59,12 @@ export const droneOperatorTaskSlice = createSlice({ setFiles: (state, action) => { state.files = action.payload; }, + setSelectedTakeOffPointOption: (state, action) => { + state.selectedTakeOffPointOption = action.payload; + }, + setSelectedTakeOffPoint: (state, action) => { + state.selectedTakeOffPoint = action.payload; + }, }, }); diff --git a/src/frontend/src/views/TaskDescription/index.tsx b/src/frontend/src/views/TaskDescription/index.tsx index daa5753e..fa7b0088 100644 --- a/src/frontend/src/views/TaskDescription/index.tsx +++ b/src/frontend/src/views/TaskDescription/index.tsx @@ -1,18 +1,21 @@ import DroneOperatorDescriptionBox from '@Components/DroneOperatorTask/DescriptionSection'; import DroneOperatorTaskHeader from '@Components/DroneOperatorTask/Header'; import MapSection from '@Components/DroneOperatorTask/MapSection'; +import useWindowDimensions from '@Hooks/useWindowDimensions'; import hasErrorBoundary from '@Utils/hasErrorBoundary'; const TaskDescription = () => { + const { width } = useWindowDimensions(); + return ( <>
-
+
-
+
- + {width >= 640 && }