diff --git a/frontend/src/components/map/draw-control.tsx b/frontend/src/components/map/draw-control.tsx index 5e91d12e..f5d322d5 100644 --- a/frontend/src/components/map/draw-control.tsx +++ b/frontend/src/components/map/draw-control.tsx @@ -6,6 +6,7 @@ import { PenIcon } from "@/components/ui/icons"; import { Map, MapMouseEvent } from "maplibre-gl"; import { calculateGeoJSONArea, + formatAreaInAppropriateUnit, MAX_TRAINING_AREA_SIZE, MIN_TRAINING_AREA_SIZE, } from "@/utils"; @@ -158,7 +159,7 @@ const DrawControl = ({

{mode === DrawingModes.RECTANGLE && featureArea === 0 ? "Click and drag to draw a rectangle." - : `Current area: ${featureArea.toFixed(2)} m²`} + : `Current area: ${formatAreaInAppropriateUnit(featureArea)}`}

{getFeedbackMessage()}

diff --git a/frontend/src/components/ui/button/button.tsx b/frontend/src/components/ui/button/button.tsx index 522506be..c481262a 100644 --- a/frontend/src/components/ui/button/button.tsx +++ b/frontend/src/components/ui/button/button.tsx @@ -3,7 +3,7 @@ import "./button.css"; import { Spinner } from "@/components/ui/spinner"; import { cn } from "@/utils"; import { ButtonSize, ButtonVariant } from "@/types"; -import useDevice from "@/hooks/use-device"; +import useScreenSize from "@/hooks/use-screen-size"; type ButtonProps = { children: React.ReactNode; @@ -29,7 +29,7 @@ const Button: React.FC = ({ }) => { const spinnerColor = variant === "primary" ? "white" : "red"; const trackColor = variant === "primary" ? "red" : "white"; - const isMobile = useDevice(); + const { isMobile } = useScreenSize(); return ( = ({ }; const inputRef = useRef(null); - const isMobile = useDevice(); + const { isMobile } = useScreenSize();; return ( = ({ handleChange, required, }) => { - const isMobile = useDevice(); + const { isMobile } = useScreenSize();; return ( = ({ }); const onDrop = useCallback((files: FileWithPath[]) => { - const validFiles = files.filter((file) => { - if (file.size > MAX_TRAINING_AREA_UPLOAD_FILE_SIZE) { - showErrorToast(undefined, `File ${file.name} is too large (max 5MB)`); + + const initialValidFiles = files.filter((file) => { + if (!file.name.endsWith(".geojson") && !file.name.endsWith(".json")) { + showErrorToast(undefined, `File ${file.name} is not a supported format`); return false; } - if (!file.name.endsWith(".geojson") && !file.name.endsWith(".json")) { - showErrorToast( - undefined, - `File ${file.name} is not a supported format`, - ); + if (file.size > MAX_TRAINING_AREA_UPLOAD_FILE_SIZE) { + showErrorToast(undefined, `File ${file.name} is too large (max 5MB)`); return false; } return true; }); - const newFiles = validFiles.map((file) => ({ - file, - id: generateUniqueId(), - })); - setAcceptedFiles((prev) => [...prev, ...newFiles]); + + const validateFiles = async () => { + const validFiles: { file: FileWithPath; id: string }[] = []; + + for (const file of initialValidFiles) { + const text = await file.text(); + try { + const geojson: FeatureCollection | Feature = JSON.parse(text); + if (validateGeoJSONArea(geojson as Feature)) { + showErrorToast(undefined, `File area for ${file.name} exceeds area limit.`); + } else { + validFiles.push({ file, id: generateUniqueId() }); + } + } catch (error) { + showErrorToast(error); + } + } + + setAcceptedFiles((prev) => [...prev, ...validFiles]); + }; + + validateFiles(); }, []); + const clearAcceptedFiles = () => { setAcceptedFiles([]); }; @@ -176,7 +192,7 @@ const FileUploadDialog: React.FC = ({ )); - const isMobile = useDevice(); + const { isMobile } = useScreenSize();; return ( = ({ { MODEL_CREATION_CONTENT.trainingArea.fileUploadDialog - .subInstruction + .fleSizeInstruction + } + + + { + MODEL_CREATION_CONTENT.trainingArea.fileUploadDialog + .aoiAreaInstruction } diff --git a/frontend/src/features/model-creation/components/training-area/training-area-item.tsx b/frontend/src/features/model-creation/components/training-area/training-area-item.tsx index af8bed38..528a340b 100644 --- a/frontend/src/features/model-creation/components/training-area/training-area-item.tsx +++ b/frontend/src/features/model-creation/components/training-area/training-area-item.tsx @@ -9,6 +9,7 @@ import { import { useDropdownMenu } from "@/hooks/use-dropdown-menu"; import { calculateGeoJSONArea, + formatAreaInAppropriateUnit, formatDuration, geoJSONDowloader, getGeoJSONFeatureBounds, @@ -31,7 +32,7 @@ import { } from "@/features/model-creation/hooks/use-training-areas"; import { useMap } from "@/app/providers/map-provider"; import { useModelsContext } from "@/app/providers/models-provider"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; const TrainingAreaItem: React.FC< TTrainingAreaFeature & { datasetId: number; offset: number } @@ -200,10 +201,12 @@ const TrainingAreaItem: React.FC< return () => clearInterval(intervalId); }, [trainingArea?.properties?.label_fetched]); - const trainingAreaSize = roundNumber( - calculateGeoJSONArea(trainingArea), - 2, - ).toLocaleString(); + + + const trainingAreaSize = useMemo(() => { + return formatAreaInAppropriateUnit(calculateGeoJSONArea(trainingArea)) + }, [trainingArea]); + return ( <>
@@ -213,10 +216,9 @@ const TrainingAreaItem: React.FC<

{trainingArea.geometry ? truncateString(trainingAreaSize, 15) : 0} - sqm

{trainingAreaLabelsMutation.isPending diff --git a/frontend/src/features/models/components/dialogs/mobile-filters-dialog.tsx b/frontend/src/features/models/components/dialogs/mobile-filters-dialog.tsx index fbb57282..da1f2e53 100644 --- a/frontend/src/features/models/components/dialogs/mobile-filters-dialog.tsx +++ b/frontend/src/features/models/components/dialogs/mobile-filters-dialog.tsx @@ -1,5 +1,5 @@ import { Dialog } from "@/components/ui/dialog"; -import useDevice from "@/hooks/use-device"; +import useScreenSize from "@/hooks/use-screen-size"; import { CategoryFilter, DateRangeFilter, @@ -36,7 +36,7 @@ const MobileModelFiltersDialog: React.FC = ({ updateQuery, disabled, }) => { - const isMobile = useDevice(); + const { isMobile } = useScreenSize();; return (

= ({ datasetId, trainingId, }) => { - const isMobile = useDevice(); + const { isMobile, isLaptop, isTablet } = useScreenSize();; return ( diff --git a/frontend/src/features/models/components/dialogs/training-area-dialog.tsx b/frontend/src/features/models/components/dialogs/training-area-dialog.tsx index 8db44b18..da497c15 100644 --- a/frontend/src/features/models/components/dialogs/training-area-dialog.tsx +++ b/frontend/src/features/models/components/dialogs/training-area-dialog.tsx @@ -1,5 +1,5 @@ import { Dialog } from "@/components/ui/dialog"; -import useDevice from "@/hooks/use-device"; +import useScreenSize from "@/hooks/use-screen-size"; import { MapComponent } from "@/components/map"; import { cn } from "@/utils"; import { DialogProps } from "@/types"; @@ -10,7 +10,7 @@ const TrainingAreaDialog: React.FC = ({ isOpened, closeDialog, }) => { - const isMobile = useDevice(); + const { isMobile } = useScreenSize();; return ( = ({ datasetId, baseModel, }) => { - const isMobile = useDevice(); + const { isMobile, isTablet, isLaptop } = useScreenSize();; return ( = ({ const { dir, file } = data; const subdirectories = dir ? await Promise.all( - Object.keys(dir).map(async (key: string) => { - const fullPath = currentDirectory - ? `${currentDirectory}/${key}` - : key; - const subDirData = await fetchDirectoryRecursive(fullPath); - return { - [key]: { - ...subDirData, - size: dir[key]?.size || 0, - length: dir[key]?.len || 0, - }, - }; - }), - ) + Object.keys(dir).map(async (key: string) => { + const fullPath = currentDirectory + ? `${currentDirectory}/${key}` + : key; + const subDirData = await fetchDirectoryRecursive(fullPath); + return { + [key]: { + ...subDirData, + size: dir[key]?.size || 0, + length: dir[key]?.len || 0, + }, + }; + }), + ) : []; return { @@ -227,7 +227,7 @@ const DirectoryTree: React.FC = ({ if (isLoading) return ; if (hasError) return ( -
{APP_CONTENT.models.modelsDetailsCard.modelFilesDialog.error}.
+
{APP_CONTENT.models.modelsDetailsCard.modelFilesDialog.error}
); return ( diff --git a/frontend/src/features/models/components/model-details-properties.tsx b/frontend/src/features/models/components/model-details-properties.tsx index 53343be9..9b8f3ede 100644 --- a/frontend/src/features/models/components/model-details-properties.tsx +++ b/frontend/src/features/models/components/model-details-properties.tsx @@ -127,6 +127,7 @@ const ModelProperties: React.FC = ({ return ; } + return ( <> = ({
)} - {/* Show logs only in modal and when status failed */} + {/* Show logs only in modal and when status failed or running */} {isTrainingDetailsDialog && - data?.status !== TrainingStatus.SUCCESS && ( + (data?.status === TrainingStatus.FAILED || data?.status === TrainingStatus.RUNNING) && ( )} @@ -304,23 +305,22 @@ export default ModelProperties; const FailedTrainingTraceBack = ({ taskId }: { taskId: string }) => { const { data, isPending } = useTrainingStatus(taskId); - const [showLogs, setShowLogs] = useState(false); + const [showLogs, setShowLogs] = useState(false); if (isPending) { return ( -
+
); } return ( -
-
+
+ {showLogs && }
); diff --git a/frontend/src/hooks/use-device.ts b/frontend/src/hooks/use-device.ts deleted file mode 100644 index 937ec09f..00000000 --- a/frontend/src/hooks/use-device.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useEffect, useState } from "react"; - -/** - * Custom hook to detect whether the current device is mobile based on the window width. - * - * This hook tracks the window width and returns `true` if the width is less than 768 pixels, - * indicating a mobile device, and `false` otherwise. It dynamically updates as the window is resized. - * - * @returns {boolean} isMobile - A boolean indicating whether the current device is considered mobile. - * - */ -const useDevice = () => { - const [isMobile, setIsMobile] = useState(false); - - const handleResize = () => { - setIsMobile(window.innerWidth < 768); - }; - - useEffect(() => { - handleResize(); - window.addEventListener("resize", handleResize); - - return () => { - window.removeEventListener("resize", handleResize); - }; - }, []); - - return isMobile; -}; - -export default useDevice; diff --git a/frontend/src/hooks/use-screen-size.ts b/frontend/src/hooks/use-screen-size.ts new file mode 100644 index 00000000..f187b688 --- /dev/null +++ b/frontend/src/hooks/use-screen-size.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; + +/** + * Custom hook to detect whether the current device is mobile, tablet based on the window width. + * + * This hook tracks the window width and returns `true` if the width is less than 768 pixels, + * indicating a mobile device, and `false` otherwise. It dynamically updates as the window is resized. + * + * @returns {object} {isMobile,isTablet} - An object indicating whether the current device is considered mobile or tablet. + * + */ +const useScreenSize = () => { + const [screenSize, setScreenSize] = useState<{ + isMobile: boolean, + isTablet: boolean, + isLaptop: boolean + }>({ + isMobile: false, + isTablet: false, + isLaptop: false + }); + + const handleResize = () => { + setScreenSize({ + isMobile: window.innerWidth < 640, + isTablet: window.innerWidth > 640 && window.innerWidth < 768, + isLaptop: window.innerWidth > 768 && window.innerWidth < 1024 + }); + }; + + useEffect(() => { + handleResize(); + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + + return screenSize; +}; + +export default useScreenSize; diff --git a/frontend/src/utils/content.ts b/frontend/src/utils/content.ts index b0374db3..aeff741c 100644 --- a/frontend/src/utils/content.ts +++ b/frontend/src/utils/content.ts @@ -1,5 +1,6 @@ import { BASE_MODELS } from "@/enums"; -import { APPLICATION_ROUTES } from "./constants"; +import { APPLICATION_ROUTES, MAX_TRAINING_AREA_SIZE, MIN_TRAINING_AREA_SIZE } from "./constants"; +import { formatAreaInAppropriateUnit } from "./geometry-utils"; export const TOAST_NOTIFICATIONS = { trainingAreasFileUploadSuccess: "Training areas created successfully.", @@ -108,7 +109,8 @@ export const MODEL_CREATION_CONTENT = { title: "Upload Training Area(s)", mainInstruction: "Drag 'n' drop some files here, or click to select files", - subInstruction: "Supports only GeoJSON (.geojson) files. (5MB max.)", + fleSizeInstruction: "Supports only GeoJSON (.geojson) files. (5MB max.)", + aoiAreaInstruction: `Area should be > ${formatAreaInAppropriateUnit(MIN_TRAINING_AREA_SIZE)} and < ${formatAreaInAppropriateUnit(MAX_TRAINING_AREA_SIZE)}.`, }, pageDescription: "Lorem ipsum dolor sit amet, consectetur adipisicing elit. Id fugit ducimus harum debitis deserunt cum quod quam rerum aliquid. Quibusdam sequi incidunt quasi delectus laudantium accusamus modi omnis maiores. Incidunt!", diff --git a/frontend/src/utils/geometry-utils.ts b/frontend/src/utils/geometry-utils.ts index e675a1ed..a3104a37 100644 --- a/frontend/src/utils/geometry-utils.ts +++ b/frontend/src/utils/geometry-utils.ts @@ -3,6 +3,7 @@ import bboxPolygon from "@turf/bbox"; import area from "@turf/area"; import { LngLatBoundsLike, Map } from "maplibre-gl"; +import { roundNumber } from "./number-utils"; /** * Calculates the area of a GeoJSON Feature or FeatureCollection. @@ -22,6 +23,23 @@ export const calculateGeoJSONArea = ( return area(geojsonFeature); }; + +/** + * Format area into human readable string. + * + * This function formats an area in square meters into human readable string. + * + * @param {number} area - The area in square meters to transform. + * + * @returns {string} The result as 12,222,000 m² or 12,222 km² + */ + +export const formatAreaInAppropriateUnit = (area: number): string => { + if (area > 1000000) { + return roundNumber(area / 1000000, 1).toLocaleString() + 'km²' + } + return roundNumber(area, 1).toLocaleString() + 'm²' +} /** * Computes the bounding box of a GeoJSON Feature. *