From 2d4299e965cd49c1440a5e52680ad2fac4fff0c7 Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Wed, 11 Jun 2025 09:22:16 +0200 Subject: [PATCH 01/19] feat: completed drawing flow --- frontend/src/app/providers/auth-provider.tsx | 4 +- frontend/src/app/router.tsx | 11 + .../routes/profile/offline-predictions.tsx | 5 + frontend/src/app/routes/start-mapping.tsx | 189 ++++++++++++++++-- .../src/components/layouts/root-layout.tsx | 6 +- .../components/map/controls/draw-control.tsx | 115 ++++++----- frontend/src/components/map/map.tsx | 24 +-- .../components/map/setups/setup-terra-draw.ts | 50 +++-- .../src/components/shared/hot-tracking.tsx | 2 +- .../src/components/shared/model-explorer.tsx | 18 +- .../ui/form/radio-group/radio-group.tsx | 4 +- .../src/components/ui/icons/draw-icon.tsx | 16 ++ frontend/src/config/index.ts | 9 +- frontend/src/constants/routes.ts | 6 + frontend/src/enums/start-mapping.ts | 11 +- .../components/dataset-area-content.tsx | 68 +++---- .../datasets/components/dataset-area-map.tsx | 1 - .../training-area/training-area-map.tsx | 51 +++-- .../components/maps/feedbacks-layer.tsx | 4 +- .../start-mapping/api/create-feedbacks.ts | 4 +- .../features/start-mapping/api/predictions.ts | 16 ++ .../offline-prediction-request-dialog.tsx | 175 ++++++++++++++++ ...line-prediction-request-success-dialog.tsx | 50 +++++ .../components/header/header.tsx | 11 + .../components/header/model-action.tsx | 24 ++- .../start-mapping/components/map/map.tsx | 30 ++- .../imagery-source-selector.tsx | 68 +++---- .../replicable-models/model-selector.tsx | 12 +- .../hooks/use-model-predictions.ts | 21 +- .../src/hooks/__tests__/use-history.test.ts | 18 +- frontend/src/hooks/use-map-instance.ts | 1 + frontend/src/services/api-routes.ts | 4 +- frontend/src/styles/index.css | 1 - frontend/src/types/api.ts | 22 +- 34 files changed, 798 insertions(+), 253 deletions(-) create mode 100644 frontend/src/app/routes/profile/offline-predictions.tsx create mode 100644 frontend/src/components/ui/icons/draw-icon.tsx create mode 100644 frontend/src/features/start-mapping/api/predictions.ts create mode 100644 frontend/src/features/start-mapping/components/dialogs/offline-prediction-request-dialog.tsx create mode 100644 frontend/src/features/start-mapping/components/dialogs/offline-prediction-request-success-dialog.tsx diff --git a/frontend/src/app/providers/auth-provider.tsx b/frontend/src/app/providers/auth-provider.tsx index 06f4d9712..2d9b19145 100644 --- a/frontend/src/app/providers/auth-provider.tsx +++ b/frontend/src/app/providers/auth-provider.tsx @@ -24,9 +24,9 @@ const AuthContext = createContext({ token: "", user: {} as TUser, authenticateUser: async () => Promise.resolve(), - logout: () => { }, + logout: () => {}, isAuthenticated: false, - setUser: () => { }, + setUser: () => {}, }); export const useAuth = () => { diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index e380b7d86..2145298d5 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -363,6 +363,17 @@ const router = createBrowserRouter([ }; }, }, + { + path: APPLICATION_ROUTES.PROFILE_OFFLINE_PREDICTIONS, + lazy: async () => { + const { UserProfileOfflinePredictionsPage } = await import( + "@/app/routes/profile/offline-predictions" + ); + return { + Component: () => , + }; + }, + }, ], }, /** diff --git a/frontend/src/app/routes/profile/offline-predictions.tsx b/frontend/src/app/routes/profile/offline-predictions.tsx new file mode 100644 index 000000000..326c24b50 --- /dev/null +++ b/frontend/src/app/routes/profile/offline-predictions.tsx @@ -0,0 +1,5 @@ +import { PageUnderConstruction } from "@/components/errors"; + +export const UserProfileOfflinePredictionsPage = () => { + return ; +}; diff --git a/frontend/src/app/routes/start-mapping.tsx b/frontend/src/app/routes/start-mapping.tsx index a0ccfaec7..1e8af138d 100644 --- a/frontend/src/app/routes/start-mapping.tsx +++ b/frontend/src/app/routes/start-mapping.tsx @@ -19,9 +19,19 @@ import { StartMappingMapComponent, StartMappingMobileDrawer, } from "@/features/start-mapping/components"; -import { constructModelCheckpointPath, geoJSONDowloader, openInJOSM, showSuccessToast } from "@/utils"; +import { + + constructModelCheckpointPath, + featureIsWithinBounds, + geoJSONDowloader, + openInJOSM, + showSuccessToast, + showWarningToast, + +} from "@/utils"; import { + MapMode, PredictedFeatureStatus, PredictionImagerySource, PredictionModel, @@ -31,7 +41,7 @@ import { ImagerySourceSelector } from "@/features/start-mapping/components/repli import { useDialog } from "@/hooks/use-dialog"; import { useModelPredictionStore } from "@/store/model-prediction-store"; import { ModelSelector } from "@/features/start-mapping/components/replicable-models/model-selector"; -import { BASE_MODELS, TileServiceType } from "@/enums"; +import { BASE_MODELS, DrawingModes, TileServiceType } from "@/enums"; import { useTileservice } from "@/hooks/use-tileservice"; import { ALL_MODEL_PREDICTIONS_FILL_LAYER_ID, @@ -39,6 +49,7 @@ import { FAIR_BASE_MODELS_PATH, OPENAERIALMAP_MOSAIC_TILES_URL, } from "@/config"; +import { OfflinePredictionRequestDialog } from "@/features/start-mapping/components/dialogs/offline-prediction-request-dialog"; export type TDownloadOptions = { name: string; @@ -56,6 +67,7 @@ export const SEARCH_PARAMS = { imagery: "imagery", predictionModelCheckpoint: "checkpoint", tileserver: "tileserver", + mode: "mode", }; export type TQueryParams = { @@ -65,7 +77,10 @@ export type TQueryParams = { export const StartMappingPage = () => { const { modelId } = useParams(); const [searchParams, setSearchParams] = useSearchParams(); - const { map, mapContainerRef } = useMapInstance(false, true); + const { map, mapContainerRef, setDrawingMode, terraDraw } = useMapInstance( + false, + true, + ); const { isSmallViewport } = useScreenSize(); const { @@ -74,6 +89,9 @@ export const StartMappingPage = () => { updateFeatureStatus, } = useModelPredictionStore(); + const [offlinePredictionAOI, setOfflinePredictionAOI] = + useState(null); + const acceptedFeatures = useMemo( () => modelPredictions.filter( @@ -94,6 +112,10 @@ export const StartMappingPage = () => { customPredictionModelCheckpointPath, setCustomPredictionModelCheckpointPath, ] = useState(""); + const currentMode = searchParams.get(SEARCH_PARAMS.mode) ?? MapMode.ONLINE; + const setCurrentMode = (newMode: MapMode) => { + updateQuery({ [SEARCH_PARAMS.mode]: newMode }); + }; const customTileServerURL = searchParams.get(SEARCH_PARAMS.tileserver) || ""; @@ -135,6 +157,12 @@ export const StartMappingPage = () => { closeDialog: closeModelSelectionDialog, } = useDialog(); + const { + openDialog: openOfflinePredictionRequestDialog, + isOpened: isOfflinePredictionRequestDialogOpened, + closeDialog: closeOfflinePredictionDialog, + } = useDialog(); + const { isError, isPending: modelInfoRequestIspending, @@ -142,8 +170,6 @@ export const StartMappingPage = () => { error, } = useModelDetails(modelId as string, !!modelId); - - const updateQuery = useCallback( (newParams: TQueryParams) => { setQuery((prev) => ({ ...prev, ...newParams })); @@ -185,24 +211,30 @@ export const StartMappingPage = () => { */ useEffect(() => { const urlCp = searchParams.get(SEARCH_PARAMS.predictionModelCheckpoint); - if (urlCp && urlCp.length > 0 && urlCp !== "undefined" && predictionModel === PredictionModel.CUSTOM) { + if ( + urlCp && + urlCp.length > 0 && + urlCp !== "undefined" && + predictionModel === PredictionModel.CUSTOM + ) { setCustomPredictionModelCheckpointPath(urlCp); setPredictionModelCheckpoint(urlCp); } }, [predictionModel]); - - useEffect(() => { /** * Only update the checkpoint if the modelInfo is available and * the predictionModel is not set or is set to the default model. */ - if (modelInfo && (!predictionModel || predictionModel === PredictionModel.DEFAULT)) { - setPredictionModelCheckpoint(constructModelCheckpointPath(modelInfo)) + if ( + modelInfo && + (!predictionModel || predictionModel === PredictionModel.DEFAULT) + ) { + setPredictionModelCheckpoint(constructModelCheckpointPath(modelInfo)); } else if (predictionModel && predictionModel !== PredictionModel.CUSTOM) { setPredictionModelCheckpoint( - FAIR_BASE_MODELS_PATH[predictionModel as BASE_MODELS] + FAIR_BASE_MODELS_PATH[predictionModel as BASE_MODELS], ); } }, [predictionModel, modelInfo]); @@ -214,7 +246,7 @@ export const StartMappingPage = () => { if (predictionImagerySource === PredictionImagerySource.Kontour) { setTileserverURL(OPENAERIALMAP_MOSAIC_TILES_URL); } - }, [predictionImagerySource]) + }, [predictionImagerySource]); /** * When the user changes the prediction imagery source, sync it to the URL. @@ -276,6 +308,105 @@ export const StartMappingPage = () => { } }, [isError, error, navigate]); + /** + * Set the drawing mode based on the current mode. + * If the current mode is OFFLINE, set the drawing mode to POLYGON. + * If the current mode is ONLINE, set the drawing mode to STATIC. + */ + useEffect(() => { + if (currentMode === MapMode.OFFLINE) { + setDrawingMode(DrawingModes.POLYGON); // or whatever mode you want + } else { + setDrawingMode(DrawingModes.STATIC); + } + }, [currentMode, setDrawingMode]); + + /** + * Effect to handle the completion of drawing in TerraDraw. + * It listens for changes in the TerraDraw instance and updates the drawn feature state. + * If a feature is drawn, it clears previous features except the last one. + */ + useEffect(() => { + if (!terraDraw) return; + + const handleDrawFinish = () => { + const features = terraDraw.getSnapshot(); + if (!features || features.length === 0) return; + + // Check if the feature is within the bounds of the OAM imagery if it exists + if (tileJSONMetadata?.bounds) { + if (!featureIsWithinBounds(tileJSONMetadata.bounds, features[0])) { + showWarningToast( + "The drawn polygon extends beyond the imagery bounds. Please ensure the AOI is within the imagery bounds.", + ); + terraDraw.removeFeatures( + features + .slice(0) + .map((f) => f.id) + .filter((id): id is string | number => id !== undefined), + ); + return; + } + } + if (features.length > 1) { + showWarningToast( + "Only one feature can be drawn at a time. Please delete the existing feature before drawing a new one.", + ); + // Remove the last drawn feature, keeping only the first one + terraDraw.removeFeatures( + features + .slice(1) + .map((f) => f.id) + .filter((id): id is string | number => id !== undefined), + ); + return; + } + setOfflinePredictionAOI(features[0]); + showSuccessToast("AOI drawn successfully."); + }; + + terraDraw.on("finish", handleDrawFinish); + + return () => { + terraDraw.off("finish", handleDrawFinish); + }; + }, [terraDraw, tileJSONMetadata]); + + /** + * Handle the drawing state change. + * If the user starts drawing, set the current mode to OFFLINE. + * If the user stops drawing, set the current mode to ONLINE and reset the drawing mode to STATIC. + */ + const handleDrawingStateChange = useCallback( + (isDrawing: boolean) => { + if (isDrawing) { + setCurrentMode(MapMode.OFFLINE); + } else { + setCurrentMode(MapMode.ONLINE); + setDrawingMode(DrawingModes.STATIC); + } + }, + [setCurrentMode], + ); + + /** + * Check if the user has drawn an AOI in offline mode. + */ + const hasDrawnAOI = useMemo(() => { + return currentMode === MapMode.OFFLINE && offlinePredictionAOI !== null; + }, [currentMode, offlinePredictionAOI]); + + /** + * Check if the offline prediction AOI is valid. + */ + const isOfflineMode = useMemo( + () => currentMode === MapMode.OFFLINE, + [currentMode], + ); + + /** + * Check if the model predictions exist. + */ const modelPredictionsExist = useMemo(() => { if (!modelPredictions) return false; return modelPredictions.length > 0; @@ -311,6 +442,18 @@ export const StartMappingPage = () => { showSuccessToast(TOAST_NOTIFICATIONS.startMapping.fileDownloadSuccess); }, [modelPredictions, modelInfo]); + const handleAOIDelete = useCallback(() => { + if (!terraDraw) return; + terraDraw.clear(); + setOfflinePredictionAOI(null); + showSuccessToast("AOI cleared successfully."); + }, [terraDraw]); + const resetOfflinePredictionModeState = useCallback(() => { + setDrawingMode(DrawingModes.STATIC); + setOfflinePredictionAOI(null); + terraDraw?.clear(); + setCurrentMode(MapMode.ONLINE); + }, [setDrawingMode, setCurrentMode, terraDraw]); const handleAcceptedFeaturesDownload = useCallback(async () => { geoJSONDowloader( { type: "FeatureCollection", features: acceptedFeatures }, @@ -411,6 +554,17 @@ export const StartMappingPage = () => { return ( <> +
{/* Base model dialog */} { modelPredictions={modelPredictions} setModelPredictions={setModelPredictions} isSmallViewport={isSmallViewport} + isOfflineMode={isOfflineMode} + hasDrawnAOI={hasDrawnAOI} + openOfflinePredictionRequestDialog={ + openOfflinePredictionRequestDialog + } />
@@ -570,6 +729,12 @@ export const StartMappingPage = () => { modelPredictions={modelPredictions} updateFeatureStatus={updateFeatureStatus} tileServerURL={tileserverURL as string} + handleDrawingStateChange={handleDrawingStateChange} + setDrawingMode={setDrawingMode} + terraDraw={terraDraw} + isOfflineMode={isOfflineMode} + hasDrawnAOI={hasDrawnAOI} + handleAOIDelete={handleAOIDelete} />
diff --git a/frontend/src/components/layouts/root-layout.tsx b/frontend/src/components/layouts/root-layout.tsx index d9f22d058..021b5d77e 100644 --- a/frontend/src/components/layouts/root-layout.tsx +++ b/frontend/src/components/layouts/root-layout.tsx @@ -8,9 +8,7 @@ import { useEffect, useState } from "react"; import { useScrollToTop } from "@/hooks/use-scroll-to-element"; import { useAuth } from "@/app/providers/auth-provider"; import { AuthenticationModal } from "@/components/auth"; -import { - BANNER_TIMEOUT_DURATION, -} from "@/config"; +import { BANNER_TIMEOUT_DURATION } from "@/config"; export const RootLayout = () => { const { pathname, state } = useLocation(); @@ -45,8 +43,6 @@ export const RootLayout = () => { */ const { modelId } = useParams(); - - return ( <> diff --git a/frontend/src/components/map/controls/draw-control.tsx b/frontend/src/components/map/controls/draw-control.tsx index 352ab824e..f039bbe1e 100644 --- a/frontend/src/components/map/controls/draw-control.tsx +++ b/frontend/src/components/map/controls/draw-control.tsx @@ -1,66 +1,75 @@ +import { useCallback } from "react"; import { DrawingModes, ToolTipPlacement } from "@/enums"; -import { PenIcon } from "@/components/ui/icons"; +import { DeleteIcon } from "@/components/ui/icons"; import { TerraDraw } from "terra-draw"; import { ToolTip } from "@/components/ui/tooltip"; -import { useCallback } from "react"; +import { DrawIcon } from "@/components/ui/icons/draw-icon"; -export const DrawControl = ({ - drawingMode, - terraDraw, - setDrawingMode, -}: { +type DrawControlProps = { drawingMode: DrawingModes; terraDraw?: TerraDraw; setDrawingMode: (newMode: DrawingModes) => void; -}) => { - const changeMode = useCallback( - (newMode: DrawingModes) => { - terraDraw?.setMode(newMode); - setDrawingMode(newMode); - }, - [terraDraw], - ); + showDeleteButton?: boolean; + onDelete?: () => void; + onDrawingStateChange?: (isDrawing: boolean) => void; + drawingIsActive?: boolean; +}; - const renderButton = ( - currentMode: DrawingModes, - activeMode: DrawingModes, - label: string, - isActive: boolean, - ) => ( - - - - ); +export const DrawControl = ({ + drawingMode, + terraDraw, + setDrawingMode, + showDeleteButton = false, + onDelete, + onDrawingStateChange, + drawingIsActive = false, +}: DrawControlProps) => { + const handleClick = useCallback(() => { + if (drawingIsActive) { + setDrawingMode(DrawingModes.STATIC); + terraDraw?.setMode(DrawingModes.STATIC); + onDrawingStateChange?.(false); + } else { + setDrawingMode(drawingMode); + terraDraw?.setMode(drawingMode); + onDrawingStateChange?.(true); + } + }, [drawingIsActive, drawingMode, setDrawingMode, terraDraw]); return ( - <> - {renderButton( - drawingMode, - DrawingModes.RECTANGLE, - drawingMode === DrawingModes.STATIC ? "Draw AOI" : "Cancel", - drawingMode === DrawingModes.RECTANGLE, +
+
+ + + +
+ + {showDeleteButton && ( + + + )} - +
); }; diff --git a/frontend/src/components/map/map.tsx b/frontend/src/components/map/map.tsx index 25ad789b1..969a22a10 100644 --- a/frontend/src/components/map/map.tsx +++ b/frontend/src/components/map/map.tsx @@ -9,7 +9,6 @@ import "maplibre-gl/dist/maplibre-gl.css"; import { GeolocationControl, FitToBounds, - DrawControl, ZoomLevel, LayerControl, ZoomControls, @@ -19,7 +18,7 @@ import { TileServiceLayer } from "@/hooks/tile-service-layer"; type MapComponentProps = { geolocationControl?: boolean; controlsPosition?: ControlsPosition; - drawControl?: boolean; + showCurrentZoom?: boolean; layerControl?: boolean; layerControlLayers?: { @@ -45,7 +44,7 @@ type MapComponentProps = { export const MapComponent: React.FC = ({ geolocationControl = false, controlsPosition = ControlsPosition.TOP_RIGHT, - drawControl = false, + showCurrentZoom = false, layerControl = false, layerControlLayers = [], @@ -56,10 +55,7 @@ export const MapComponent: React.FC = ({ bounds, mapContainerRef, map, - terraDraw, - drawingMode, zoomControls = true, - setDrawingMode, tileServiceURL, hasTileServiceLayer = false, }) => { @@ -68,21 +64,13 @@ export const MapComponent: React.FC = ({ {map ? ( <>
{zoomControls ? : null} {geolocationControl && } - {drawControl && terraDraw && drawingMode && setDrawingMode && ( - - )}
{fitToBounds && (
diff --git a/frontend/src/components/map/setups/setup-terra-draw.ts b/frontend/src/components/map/setups/setup-terra-draw.ts index b4d8d60c7..5c551c30e 100644 --- a/frontend/src/components/map/setups/setup-terra-draw.ts +++ b/frontend/src/components/map/setups/setup-terra-draw.ts @@ -4,6 +4,8 @@ import { ValidateNotSelfIntersecting, TerraDrawRectangleMode, TerraDrawExtend, + TerraDrawSelectMode, + TerraDrawPolygonMode, } from "terra-draw"; import { TerraDrawMapLibreGLAdapter } from "terra-draw-maplibre-gl-adapter"; import { @@ -30,21 +32,39 @@ export const setupTerraDraw = (map: maplibregl.Map) => { // })(), // }, modes: [ - // new TerraDrawSelectMode({ - // flags: { - // arbitary: { - // feature: {}, - // }, - // rectangle: { - // feature: { - // draggable: true, - // coordinates: { - // resizable: "opposite", - // }, - // }, - // }, - // }, - // }), + new TerraDrawSelectMode({ + flags: { + arbitary: { + feature: {}, + }, + rectangle: { + feature: { + draggable: true, + coordinates: { + resizable: "opposite", + }, + }, + }, + polygon: { + feature: { + draggable: true, + coordinates: { + midpoints: true, + draggable: true, + deletable: true, + }, + }, + }, + }, + }), + new TerraDrawPolygonMode({ + validation: (feature, { updateType }) => { + if (updateType === "finish" || updateType === "commit") { + return ValidateNotSelfIntersecting(feature); + } + return { valid: true }; + }, + }), new TerraDrawRectangleMode({ validation: (feature, { updateType }) => { if (updateType === "finish" || updateType === "commit") { diff --git a/frontend/src/components/shared/hot-tracking.tsx b/frontend/src/components/shared/hot-tracking.tsx index 0ebfc22e3..46a50012d 100644 --- a/frontend/src/components/shared/hot-tracking.tsx +++ b/frontend/src/components/shared/hot-tracking.tsx @@ -28,4 +28,4 @@ export const HotTracking = ({ homepagePath = APPLICATION_ROUTES.HOMEPAGE }) => { }, [pathname, homepagePath]); return null; -}; \ No newline at end of file +}; diff --git a/frontend/src/components/shared/model-explorer.tsx b/frontend/src/components/shared/model-explorer.tsx index 33d275f53..b49280734 100644 --- a/frontend/src/components/shared/model-explorer.tsx +++ b/frontend/src/components/shared/model-explorer.tsx @@ -33,7 +33,8 @@ export const ModelExplorer = ({ createRoute, createButtonAlt, userId, - datasetId, disableStatusFilter + datasetId, + disableStatusFilter, }: { disableCreateNewButton?: boolean; title?: string; @@ -122,14 +123,13 @@ export const ModelExplorer = ({ } /> - { - disableStatusFilter ? null : - - } + {disableStatusFilter ? null : ( + + )} {/* Mobile filters */}
diff --git a/frontend/src/components/ui/form/radio-group/radio-group.tsx b/frontend/src/components/ui/form/radio-group/radio-group.tsx index 54fb936cc..8e7ad6e55 100644 --- a/frontend/src/components/ui/form/radio-group/radio-group.tsx +++ b/frontend/src/components/ui/form/radio-group/radio-group.tsx @@ -16,6 +16,7 @@ type RadioGroupProps = { onChange: (value: string) => void; withTooltip?: boolean; labelClassName?: string; + className?: string; }; export const RadioGroup = ({ @@ -25,6 +26,7 @@ export const RadioGroup = ({ onChange, withTooltip = false, labelClassName = "text-body-4", + className = "", }: RadioGroupProps) => { return ( onChange(e.target.value)} > -
+
{options.map((option) => ( {option.label} diff --git a/frontend/src/components/ui/icons/draw-icon.tsx b/frontend/src/components/ui/icons/draw-icon.tsx new file mode 100644 index 000000000..f7c3274d3 --- /dev/null +++ b/frontend/src/components/ui/icons/draw-icon.tsx @@ -0,0 +1,16 @@ +import { IconProps } from "@/types"; +import React from "react"; + +export const DrawIcon: React.FC = (props) => ( + + + +); diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts index 625ffed92..64e39b613 100644 --- a/frontend/src/config/index.ts +++ b/frontend/src/config/index.ts @@ -233,8 +233,7 @@ export const MIN_ZOOM_LEVEL_FOR_TRAINING_AREA_LABELS: number = parseIntEnv( * OSM Basemap style. */ export const MAP_STYLES: Record = { - OSM: - { + OSM: { version: 8, // "glyphs": "mapbox://fonts/mapbox/{fontstack}/{range}.pbf", //https://fonts.openmaptiles.org/{fontstack}/{range}.pbf @@ -375,7 +374,6 @@ export const MATOMO_TRACKING_URL: string = parseStringEnv( */ export const HOT_TRACKING_HTML_TAG_NAME: string = "hot-tracking"; - export const BANNER_TIMEOUT_DURATION: number = parseIntEnv( ENVS.BANNER_TIMEOUT_DURATION, 3000, @@ -445,7 +443,7 @@ const REFRESH_BUFFER_MS: number = 1000; */ export const KPI_STATS_CACHE_TIME_MS: number = parseIntEnv(ENVS.KPI_STATS_CACHE_TIME, DEFAULT_KPI_STATS_CACHE_TIME_SECONDS) * - 1000 + + 1000 + REFRESH_BUFFER_MS; /** @@ -478,7 +476,8 @@ export const FAIR_BASE_MODELS_PATH: Record = { [BASE_MODELS.YOLOV8_V2]: `${FAIR_MODELS_BASE_PATH}/basemodels/yolo/yolov8s_v2-seg.onnx`, }; -export const OPENAERIALMAP_MOSAIC_TILES_URL = "https://apps.kontur.io/raster-tiler/oam/mosaic/{z}/{x}/{y}.png" +export const OPENAERIALMAP_MOSAIC_TILES_URL = + "https://apps.kontur.io/raster-tiler/oam/mosaic/{z}/{x}/{y}.png"; /** * The default offset step for the training labels offset controller. */ diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 915b02ca4..5a8777627 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -59,6 +59,7 @@ export const APPLICATION_ROUTES = { PROFILE_SETTINGS: "/profile/settings", PROFILE_MODELS: "/profile/models", PROFILE_DATASETS: "/profile/datasets", + PROFILE_OFFLINE_PREDICTIONS: "/profile/offline-predictions", }; export const HOT_PRIVACY_POLICY_URL: string = "https://www.hotosm.org/privacy"; @@ -81,6 +82,11 @@ export const PROFILE_NAVIGATION_TABS: TProfileNavigationTabs = [ href: APPLICATION_ROUTES.PROFILE_DATASETS, active: true, }, + { + title: "Offline Predictions", + href: APPLICATION_ROUTES.PROFILE_OFFLINE_PREDICTIONS, + active: true, + }, { title: "Settings", href: APPLICATION_ROUTES.PROFILE_SETTINGS, diff --git a/frontend/src/enums/start-mapping.ts b/frontend/src/enums/start-mapping.ts index a4e749631..63b2d458d 100644 --- a/frontend/src/enums/start-mapping.ts +++ b/frontend/src/enums/start-mapping.ts @@ -19,8 +19,13 @@ export enum PredictedFeatureStatus { UNTOUCHED = "untouched", } - export enum FeedbackType { ACCEPT = "ACCEPT", - REJECT = "REJECT" -} \ No newline at end of file + REJECT = "REJECT", +} + +export enum MapMode { + ONLINE = "online", + // This is enabled when the user is trying to draw an AOI for offline predictions. + OFFLINE = "offline", +} diff --git a/frontend/src/features/datasets/components/dataset-area-content.tsx b/frontend/src/features/datasets/components/dataset-area-content.tsx index b9fed79a3..c57611a12 100644 --- a/frontend/src/features/datasets/components/dataset-area-content.tsx +++ b/frontend/src/features/datasets/components/dataset-area-content.tsx @@ -7,43 +7,43 @@ import { TTrainingDataset } from "@/types"; import { SHOELACE_SIZES } from "@/enums"; export const DatasetAreaContent: React.FC<{ - trainingDataset: TTrainingDataset; + trainingDataset: TTrainingDataset; }> = ({ trainingDataset }) => { - const { map, mapContainerRef } = useMapInstance(); + const { map, mapContainerRef } = useMapInstance(); - const { - data: trainingAreasData, - isPending: trainingAreaIsPending, - isError, - refetch, - } = useGetTrainingAreas(trainingDataset.id, 0); + const { + data: trainingAreasData, + isPending: trainingAreaIsPending, + isError, + refetch, + } = useGetTrainingAreas(trainingDataset.id, 0); - return ( -
- {trainingAreaIsPending && ( -
- - Loading dataset area... -
- )} - - {isError && ( -
-

Error loading dataset area.

- -
- )} + return ( +
+ {trainingAreaIsPending && ( +
+ + Loading dataset area... +
+ )} - + {isError && ( +
+

Error loading dataset area.

+
- ); + )} + + +
+ ); }; diff --git a/frontend/src/features/datasets/components/dataset-area-map.tsx b/frontend/src/features/datasets/components/dataset-area-map.tsx index 8c81f5cae..e5f0cf35d 100644 --- a/frontend/src/features/datasets/components/dataset-area-map.tsx +++ b/frontend/src/features/datasets/components/dataset-area-map.tsx @@ -76,7 +76,6 @@ export const DatasetAreaMap = ({ 0 ? [ - { - value: "Training Labels", - subLayers: [ - trainingAreasLabelsFillLayerId, - trainingAreasLabelsOutlineLayerId, - ], - }, - ] + { + value: "Training Labels", + subLayers: [ + trainingAreasLabelsFillLayerId, + trainingAreasLabelsOutlineLayerId, + ], + }, + ] : []), ...(data?.results?.features?.length ? [ - { - value: "Training Areas", - subLayers: [ - trainingAreasOutlineLayerId, - trainingAreasFillLayerId, - ], - }, - ] + { + value: "Training Areas", + subLayers: [ + trainingAreasOutlineLayerId, + trainingAreasFillLayerId, + ], + }, + ] : []), ]} > @@ -256,7 +253,7 @@ const TrainingAreaMap = ({ )} {!trainingAreasLabelsIsPending && - currentZoom >= MIN_ZOOM_LEVEL_FOR_TRAINING_AREA_LABELS ? ( + currentZoom >= MIN_ZOOM_LEVEL_FOR_TRAINING_AREA_LABELS ? ( {getFeedbackMessage()}

)} +
+ {terraDraw && ( + + )} +
); }; diff --git a/frontend/src/features/models/components/maps/feedbacks-layer.tsx b/frontend/src/features/models/components/maps/feedbacks-layer.tsx index 3fd79a0b8..2cdb356be 100644 --- a/frontend/src/features/models/components/maps/feedbacks-layer.tsx +++ b/frontend/src/features/models/components/maps/feedbacks-layer.tsx @@ -30,7 +30,9 @@ export const FeedbacksLayer = ({ properties: { ...feature.properties, comment_length: - feature?.properties && "comments" in feature.properties && feature.properties.comments + feature?.properties && + "comments" in feature.properties && + feature.properties.comments ? feature.properties.comments.length : 0, }, diff --git a/frontend/src/features/start-mapping/api/create-feedbacks.ts b/frontend/src/features/start-mapping/api/create-feedbacks.ts index 2228e7b13..c7b4dd46a 100644 --- a/frontend/src/features/start-mapping/api/create-feedbacks.ts +++ b/frontend/src/features/start-mapping/api/create-feedbacks.ts @@ -27,7 +27,7 @@ export const createFeedback = async ({ source_imagery, zoom_level, training, - action: FeedbackType.REJECT + action: FeedbackType.REJECT, }) ).data; }; @@ -60,7 +60,7 @@ export const createApprovedPrediction = async ({ training, geom, user, - action: FeedbackType.ACCEPT + action: FeedbackType.ACCEPT, }) ).data; }; diff --git a/frontend/src/features/start-mapping/api/predictions.ts b/frontend/src/features/start-mapping/api/predictions.ts new file mode 100644 index 000000000..22dc89fca --- /dev/null +++ b/frontend/src/features/start-mapping/api/predictions.ts @@ -0,0 +1,16 @@ +import { API_ENDPOINTS, apiClient } from "@/services"; +import { TOfflinePredictionsConfig } from "@/types"; + +export const submitOfflinePredictionRequest = async ({ + geom, + name, + config, +}: TOfflinePredictionsConfig): Promise => { + return await ( + await apiClient.post(API_ENDPOINTS.CREATE_OFFLINE_PREDICTION, { + geom, + config, + name, + }) + ).data; +}; diff --git a/frontend/src/features/start-mapping/components/dialogs/offline-prediction-request-dialog.tsx b/frontend/src/features/start-mapping/components/dialogs/offline-prediction-request-dialog.tsx new file mode 100644 index 000000000..4474ab35a --- /dev/null +++ b/frontend/src/features/start-mapping/components/dialogs/offline-prediction-request-dialog.tsx @@ -0,0 +1,175 @@ +import { Dialog } from "@/components/ui/dialog"; +import { Divider } from "@/components/ui/divider"; +import { FormLabel, Input } from "@/components/ui/form"; +import { RadioGroup } from "@/components/ui/form/radio-group/radio-group"; +import { useState } from "react"; +import { ModelSettings } from "@/features/start-mapping/components/model-settings"; +import { Feature, TModelDetails, TQueryParams } from "@/types"; +import { Button } from "@/components/ui/button"; +import { ButtonVariant } from "@/enums"; +import { Alert } from "@/components/ui/alert"; +import { showErrorToast } from "@/utils"; +import { useSubmitOfflinePredictionsRequest } from "@/features/start-mapping/hooks/use-model-predictions"; +import { SEARCH_PARAMS } from "@/app/routes/start-mapping"; +import { useParams } from "react-router-dom"; +import { Geometry } from "geojson"; +import { OfflinePredictionRequestSuccess } from "@/features/start-mapping/components/dialogs/offline-prediction-request-success-dialog"; +import { useDialog } from "@/hooks/use-dialog"; + +const MINIMUM_PREDICTION_NAME_LENGTH = 2; +const MAXIMUM_PREDICTION_NAME_LENGTH = 50; +export const OfflinePredictionRequestDialog = ({ + isOpen, + onClose, + query, + updateQuery, + drawnAOI, + predictionModelCheckpoint, + tileServerURL, + modelInfo, + resetOfflinePredictionModeState, +}: { + isOpen: boolean; + onClose: () => void; + query: TQueryParams; + updateQuery: (newParams: TQueryParams) => void; + drawnAOI: Feature | null; + modelInfo: TModelDetails; + tileServerURL: string | undefined; + predictionModelCheckpoint: string; + resetOfflinePredictionModeState: () => void; +}) => { + const { modelId } = useParams(); + const [predictionRequestName, setPredictionRequestName] = + useState(""); + const [zoomLevel, setZoomLevel] = useState("18"); + const { isOpened, openDialog, closeDialog } = useDialog(); + const modelPredictionMutation = useSubmitOfflinePredictionsRequest({ + mutationConfig: { + onSuccess: () => { + // show success dialog + openDialog(); + // reset state + resetOfflinePredictionModeState(); + setPredictionRequestName(""); + setZoomLevel("18"); + onClose(); + }, + onError: (error) => showErrorToast(error), + }, + }); + + return ( + <> + + + + + Set the parameters for your prediction request. Selected model and + imagery will be used to generate predictions for the selected zoom + level. You can also set advanced settings for your prediction + request. + + + +
+ setPredictionRequestName(e.target.value)} + value={predictionRequestName} + toolTipContent={ + "Set a name for your prediction request. This will help you identify it later." + } + label={"Prediction Request Name"} + labelWithTooltip + placeholder={"Enter a name for your prediction request"} + showBorder + maxLength={MAXIMUM_PREDICTION_NAME_LENGTH} + minLength={MINIMUM_PREDICTION_NAME_LENGTH} + /> + +
+ + setZoomLevel(selection)} + /> +
+
+ +
+ +
+
+
+ + +
+
+
+ + ); +}; diff --git a/frontend/src/features/start-mapping/components/dialogs/offline-prediction-request-success-dialog.tsx b/frontend/src/features/start-mapping/components/dialogs/offline-prediction-request-success-dialog.tsx new file mode 100644 index 000000000..7aecfc669 --- /dev/null +++ b/frontend/src/features/start-mapping/components/dialogs/offline-prediction-request-success-dialog.tsx @@ -0,0 +1,50 @@ +import { ModelFormConfirmation } from "@/assets/images"; +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { Image } from "@/components/ui/image"; +import { APPLICATION_ROUTES } from "@/constants"; +import { ButtonVariant } from "@/enums/common"; +import { useNavigate } from "react-router-dom"; + +export const OfflinePredictionRequestSuccess = ({ + isOpen, + onClose, +}: { + isOpen: boolean; + onClose: () => void; +}) => { + const navigate = useNavigate(); + + return ( + +
+
+ Success Icon +
+

Prediction Request Sent

+

+ We have received the request to run prediction on your specified area. + You will be notified when the prediction is done. +

+
+ + +
+
+
+ ); +}; diff --git a/frontend/src/features/start-mapping/components/header/header.tsx b/frontend/src/features/start-mapping/components/header/header.tsx index 688983198..18ee44f3e 100644 --- a/frontend/src/features/start-mapping/components/header/header.tsx +++ b/frontend/src/features/start-mapping/components/header/header.tsx @@ -53,6 +53,9 @@ const StartMappingHeader = memo( modelPredictions, setModelPredictions, isSmallViewport, + isOfflineMode, + hasDrawnAOI, + openOfflinePredictionRequestDialog, }: { modelPredictionsExist: boolean; modelInfoRequestIsPending: boolean; @@ -92,6 +95,9 @@ const StartMappingHeader = memo( modelPredictions: TModelPredictionFeature[]; setModelPredictions: (features: TModelPredictionFeature[]) => void; isSmallViewport: boolean; + isOfflineMode: boolean; + hasDrawnAOI: boolean; + openOfflinePredictionRequestDialog: () => void; }) => { return (
@@ -189,6 +195,11 @@ const StartMappingHeader = memo( predictionModelCheckpoint={predictionModelCheckpoint} setModelPredictions={setModelPredictions} modelPredictions={modelPredictions} + isOfflineMode={isOfflineMode} + hasDrawnAOI={hasDrawnAOI} + openOfflinePredictionRequestDialog={ + openOfflinePredictionRequestDialog + } />
diff --git a/frontend/src/features/start-mapping/components/header/model-action.tsx b/frontend/src/features/start-mapping/components/header/model-action.tsx index 9f987163a..60772b10c 100644 --- a/frontend/src/features/start-mapping/components/header/model-action.tsx +++ b/frontend/src/features/start-mapping/components/header/model-action.tsx @@ -24,6 +24,9 @@ const ModelAction = ({ predictionModelCheckpoint, modelPredictions, setModelPredictions, + isOfflineMode = false, + hasDrawnAOI = false, + openOfflinePredictionRequestDialog, }: { map: Map | null; query: TQueryParams; @@ -32,6 +35,9 @@ const ModelAction = ({ predictionModelCheckpoint: string; modelPredictions: TModelPredictionFeature[]; setModelPredictions: (features: TModelPredictionFeature[]) => void; + isOfflineMode?: boolean; + hasDrawnAOI?: boolean; + openOfflinePredictionRequestDialog?: () => void; }) => { const { modelId } = useParams(); const [predictionZoomLevel, setPredictionZoomLevel] = useState( @@ -95,10 +101,12 @@ const ModelAction = ({ }, [getTrainingConfig, modelPredictionMutation, map, currentZoom]); const disablePredictionButton = - currentZoom < MIN_ZOOM_LEVEL_FOR_START_MAPPING_PREDICTION || - modelPredictionMutation.isPending || - tileServerURL?.length === 0 || - predictionModelCheckpoint?.length === 0; + (currentZoom < MIN_ZOOM_LEVEL_FOR_START_MAPPING_PREDICTION || + modelPredictionMutation.isPending || + tileServerURL?.length === 0 || + predictionModelCheckpoint?.length === 0 || + isOfflineMode) && + !hasDrawnAOI; return (
diff --git a/frontend/src/features/start-mapping/components/map/map.tsx b/frontend/src/features/start-mapping/components/map/map.tsx index 2e26540d2..d5222dd0a 100644 --- a/frontend/src/features/start-mapping/components/map/map.tsx +++ b/frontend/src/features/start-mapping/components/map/map.tsx @@ -1,11 +1,11 @@ import useScreenSize from "@/hooks/use-screen-size"; -import { ControlsPosition, TileServiceType } from "@/enums"; +import { ControlsPosition, DrawingModes, TileServiceType } from "@/enums"; import { LngLatBoundsLike, Map } from "maplibre-gl"; import { Legend, PredictedFeatureActionPopup, } from "@/features/start-mapping/components"; -import { MapComponent, MapCursorToolTip } from "@/components/map"; +import { DrawControl, MapComponent, MapCursorToolTip } from "@/components/map"; import { RefObject, useEffect, useMemo } from "react"; import { TileJSON, TModelPredictionFeature } from "@/types"; @@ -27,6 +27,7 @@ import { OPENAERIALMAP_TILESERVER_URL_REGEX_PATTERN, } from "@/utils"; import { useInitialHashFit } from "@/hooks/use-map-hash-sync"; +import { TerraDraw } from "terra-draw"; export const StartMappingMapComponent = ({ map, @@ -41,6 +42,12 @@ export const StartMappingMapComponent = ({ updateFeatureStatus, tileServerURL, layers, + handleDrawingStateChange, + setDrawingMode, + terraDraw, + isOfflineMode, + hasDrawnAOI, + handleAOIDelete, }: { trainingId: number; map: Map | null; @@ -61,6 +68,12 @@ export const StartMappingMapComponent = ({ updatedProperties: Partial, ) => void; tileServerURL: string; + handleDrawingStateChange: (isDrawing: boolean) => void; + setDrawingMode: (mode: DrawingModes) => void; + terraDraw?: TerraDraw; + isOfflineMode: boolean; + hasDrawnAOI?: boolean; + handleAOIDelete?: () => void; }) => { const { isSmallViewport } = useScreenSize(); const currentZoom = useMapStore.getState().zoom; @@ -193,6 +206,19 @@ export const StartMappingMapComponent = ({ /> )} {memoizedToolTip} +
+ {terraDraw && ( + + )} +
{modelPredictionsExist && !isSmallViewport && } ); diff --git a/frontend/src/features/start-mapping/components/replicable-models/imagery-source-selector.tsx b/frontend/src/features/start-mapping/components/replicable-models/imagery-source-selector.tsx index 5331165f5..c3af04bd7 100644 --- a/frontend/src/features/start-mapping/components/replicable-models/imagery-source-selector.tsx +++ b/frontend/src/features/start-mapping/components/replicable-models/imagery-source-selector.tsx @@ -16,24 +16,24 @@ const PredictionImagerySources: Array<{ url?: string; tooltip: string; }> = [ - { - value: PredictionImagerySource.ModelDefault, - label: "Model Default", - url: "", - tooltip: "Default imagery for the model.", - }, - { - value: PredictionImagerySource.CustomImagery, - label: "Custom Imagery", - tooltip: "Use a custom XYZ/TMS tile server URL.", - }, - { - value: PredictionImagerySource.Kontour, - label: "OpenAerialMap Mosaic", - url: OPENAERIALMAP_MOSAIC_TILES_URL, - tooltip: "All OpenAerialMap images in one mosaic layer, by Kontur.io.", - }, - ]; + { + value: PredictionImagerySource.ModelDefault, + label: "Model Default", + url: "", + tooltip: "Default imagery for the model.", + }, + { + value: PredictionImagerySource.CustomImagery, + label: "Custom Imagery", + tooltip: "Use a custom XYZ/TMS tile server URL.", + }, + { + value: PredictionImagerySource.Kontour, + label: "OpenAerialMap Mosaic", + url: OPENAERIALMAP_MOSAIC_TILES_URL, + tooltip: "All OpenAerialMap images in one mosaic layer, by Kontur.io.", + }, +]; export const ImagerySourceSelector = ({ setPredictionImagerySource, @@ -141,24 +141,24 @@ export const ImagerySourceSelector = ({ /> {localPredictionImagerySource === PredictionImagerySource.CustomImagery && ( -
- setLocalTileServerURL(e)} - tileServerURL={localTileServerURL} - validationStateUpdateCallback={setLocalTileServiceTypeValidity} - setTileServiceType={setLocalTileServiceType} - size={SHOELACE_SIZES.SMALL} - /> -
- )} +
+ setLocalTileServerURL(e)} + tileServerURL={localTileServerURL} + validationStateUpdateCallback={setLocalTileServiceTypeValidity} + setTileServiceType={setLocalTileServiceType} + size={SHOELACE_SIZES.SMALL} + /> +
+ )} {localPredictionImagerySource !== PredictionImagerySource.ModelDefault && ( - - {START_MAPPING_PAGE_CONTENT.replicableModel.info} - - )} + + {START_MAPPING_PAGE_CONTENT.replicableModel.info} + + )}
diff --git a/frontend/src/components/shared/model-explorer.tsx b/frontend/src/components/shared/model-explorer.tsx index b49280734..9c4d75bd5 100644 --- a/frontend/src/components/shared/model-explorer.tsx +++ b/frontend/src/components/shared/model-explorer.tsx @@ -34,7 +34,7 @@ export const ModelExplorer = ({ createButtonAlt, userId, datasetId, - disableStatusFilter, + disableStatusFilter, status }: { disableCreateNewButton?: boolean; title?: string; @@ -43,6 +43,7 @@ export const ModelExplorer = ({ userId?: number; datasetId?: number; disableStatusFilter?: boolean; + status?: number; }) => { const { isOpened, openDialog, closeDialog } = useDialog(); @@ -54,7 +55,7 @@ export const ModelExplorer = ({ isPlaceholderData, query, updateQuery, - } = useModelsListFilters(undefined, userId, datasetId); + } = useModelsListFilters(status, userId, datasetId); const navigate = useNavigate(); const handleClick = () => { diff --git a/frontend/src/features/start-mapping/api/predictions.ts b/frontend/src/features/start-mapping/api/predictions.ts index 22dc89fca..a992adf25 100644 --- a/frontend/src/features/start-mapping/api/predictions.ts +++ b/frontend/src/features/start-mapping/api/predictions.ts @@ -3,14 +3,14 @@ import { TOfflinePredictionsConfig } from "@/types"; export const submitOfflinePredictionRequest = async ({ geom, - name, + description, config, }: TOfflinePredictionsConfig): Promise => { return await ( await apiClient.post(API_ENDPOINTS.CREATE_OFFLINE_PREDICTION, { geom, config, - name, + description, }) ).data; }; diff --git a/frontend/src/features/start-mapping/components/dialogs/offline-prediction-request-dialog.tsx b/frontend/src/features/start-mapping/components/dialogs/offline-prediction-request-dialog.tsx index 4474ab35a..378ff675a 100644 --- a/frontend/src/features/start-mapping/components/dialogs/offline-prediction-request-dialog.tsx +++ b/frontend/src/features/start-mapping/components/dialogs/offline-prediction-request-dialog.tsx @@ -146,7 +146,7 @@ export const OfflinePredictionRequestDialog = ({ } onClick={() => { modelPredictionMutation.mutateAsync({ - name: predictionRequestName, + description: predictionRequestName, geom: drawnAOI?.geometry as Geometry, config: { tolerance: query[SEARCH_PARAMS.tolerance] as number, diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index cf0dd2383..ec71a9aca 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -231,7 +231,7 @@ export type TModelPredictionsConfig = TPredictionsConfig & { }; export type TOfflinePredictionsConfig = { - name: string; + description: string; config: TPredictionsConfig; geom: Geometry; }; From 5c6c176d05a61608c0a7cdacc7c1c8560e505727 Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Wed, 25 Jun 2025 06:29:51 +0200 Subject: [PATCH 03/19] chore: finalized upload flow --- frontend/.env.sample | 8 +- frontend/src/app/routes/start-mapping.tsx | 222 ++++++++++--- .../layouts/navbar/user-profile.tsx | 6 + frontend/src/components/map/map.tsx | 9 +- .../components/map/setups/setup-terra-draw.ts | 2 +- .../shared/modals}/file-upload-dialog.tsx | 34 +- .../src/components/shared/model-explorer.tsx | 3 +- .../components/ui/icons/file-upload-icon.tsx | 16 + frontend/src/components/ui/icons/index.ts | 1 + frontend/src/config/index.ts | 4 +- .../constants/ui-contents/models-content.ts | 2 +- .../constants/ui-contents/shared-content.ts | 1 + .../training-area/training-area-item.tsx | 2 +- .../training-area/training-area-map.tsx | 35 +-- .../training-area/training-area.tsx | 2 +- .../offline-prediction-request-dialog.tsx | 296 +++++++++--------- ...line-prediction-request-success-dialog.tsx | 74 ++--- .../start-mapping/components/map/map.tsx | 39 ++- frontend/src/hooks/use-map-instance.ts | 6 +- frontend/src/types/api.ts | 10 +- frontend/src/types/ui-contents.ts | 1 + frontend/src/utils/geo/geo-utils.ts | 4 + frontend/src/utils/geo/geometry-utils.ts | 15 +- 23 files changed, 495 insertions(+), 297 deletions(-) rename frontend/src/{features/model-creation/components/dialogs => components/shared/modals}/file-upload-dialog.tsx (90%) create mode 100644 frontend/src/components/ui/icons/file-upload-icon.tsx diff --git a/frontend/.env.sample b/frontend/.env.sample index c00219036..18954671a 100644 --- a/frontend/.env.sample +++ b/frontend/.env.sample @@ -30,10 +30,10 @@ VITE_MAX_TRAINING_AREA_SIZE = 5000000 # Default value: 5797 square meters. VITE_MIN_TRAINING_AREA_SIZE = 5797 -# The maximum file size allowed for training area upload, measure in bytes. -# Data type: Positive Integer (e.g., 500000). -# Default value: 5242880 bytes (5 MB). -VITE_MAX_TRAINING_AREA_UPLOAD_FILE_SIZE = 5242880 +# The maximum file size allowed for training area upload, measured in bytes. +# Data type: Positive Integer (e.g., 1048576 for 1 MB). +# Default value: 1048576 bytes (1 MB). +VITE_MAX_TRAINING_AREA_UPLOAD_FILE_SIZE = 1048576 # The current version of the application. # This is used in the OSM redirect callback when a training area is opened in OSM. diff --git a/frontend/src/app/routes/start-mapping.tsx b/frontend/src/app/routes/start-mapping.tsx index 1e8af138d..81d90e3ba 100644 --- a/frontend/src/app/routes/start-mapping.tsx +++ b/frontend/src/app/routes/start-mapping.tsx @@ -19,15 +19,15 @@ import { StartMappingMapComponent, StartMappingMobileDrawer, } from "@/features/start-mapping/components"; +import FileUploadDialog from "@/components/shared/modals/file-upload-dialog"; import { - constructModelCheckpointPath, featureIsWithinBounds, geoJSONDowloader, openInJOSM, showSuccessToast, showWarningToast, - + uuid4, } from "@/utils"; import { @@ -50,6 +50,7 @@ import { OPENAERIALMAP_MOSAIC_TILES_URL, } from "@/config"; import { OfflinePredictionRequestDialog } from "@/features/start-mapping/components/dialogs/offline-prediction-request-dialog"; +import { GeoJSONStoreFeatures } from "terra-draw"; export type TDownloadOptions = { name: string; @@ -81,6 +82,7 @@ export const StartMappingPage = () => { false, true, ); + const { isSmallViewport } = useScreenSize(); const { @@ -101,6 +103,7 @@ export const StartMappingPage = () => { ); const navigate = useNavigate(); + const [openMobileDrawer, setOpenMobileDrawer] = useState(isSmallViewport); @@ -163,6 +166,11 @@ export const StartMappingPage = () => { closeDialog: closeOfflinePredictionDialog, } = useDialog(); + const { + openDialog: openFileUploadDialog, + isOpened: isFileUploadDialogOpened, + closeDialog: closeFileUploadDialog, + } = useDialog(); const { isError, isPending: modelInfoRequestIspending, @@ -315,63 +323,119 @@ export const StartMappingPage = () => { */ useEffect(() => { if (currentMode === MapMode.OFFLINE) { - setDrawingMode(DrawingModes.POLYGON); // or whatever mode you want + setDrawingMode(DrawingModes.POLYGON); } else { setDrawingMode(DrawingModes.STATIC); } }, [currentMode, setDrawingMode]); /** - * Effect to handle the completion of drawing in TerraDraw. - * It listens for changes in the TerraDraw instance and updates the drawn feature state. - * If a feature is drawn, it clears previous features except the last one. + * Check if the user has drawn an AOI in offline mode. */ - useEffect(() => { - if (!terraDraw) return; + const hasDrawnAOI = useMemo(() => { + return offlinePredictionAOI !== null; + }, [currentMode, offlinePredictionAOI]); + + /** + * Check if the offline prediction AOI is valid. + */ + const isOfflineMode = useMemo( + () => currentMode === MapMode.OFFLINE, + [currentMode], + ); + + /** + * Handle the finish event of the TerraDraw instance. + * It checks if the drawn feature is within the bounds of the OAM imagery if it exists. + * If the feature is valid, it sets the offline prediction AOI state. + * If there are multiple features drawn, it removes all but the first one. + */ + const handleDrawFinish = useCallback( + (feature?: Feature) => { + if (!terraDraw) return; + + let features: Feature[] = []; + + if (feature) { + // If a geometry is provided, wrap it as a Feature + features = [feature]; + } else { + // Otherwise, get features from terraDraw + features = terraDraw.getSnapshot(); + } - const handleDrawFinish = () => { - const features = terraDraw.getSnapshot(); if (!features || features.length === 0) return; // Check if the feature is within the bounds of the OAM imagery if it exists if (tileJSONMetadata?.bounds) { if (!featureIsWithinBounds(tileJSONMetadata.bounds, features[0])) { showWarningToast( - "The drawn polygon extends beyond the imagery bounds. Please ensure the AOI is within the imagery bounds.", - ); - terraDraw.removeFeatures( - features - .slice(0) - .map((f) => f.id) - .filter((id): id is string | number => id !== undefined), + "The drawn polygon extends beyond the imagery bounds. Please ensure the polygon AOI is within the imagery bounds.", ); + if (!feature && terraDraw) { + terraDraw.removeFeatures( + features + .slice(0) + .map((f) => f.id) + .filter((id): id is string | number => id !== undefined), + ); + } return; } } if (features.length > 1) { showWarningToast( - "Only one feature can be drawn at a time. Please delete the existing feature before drawing a new one.", + "Only one polygon can be drawn at a time. Please delete the existing polygon before drawing a new one.", ); // Remove the last drawn feature, keeping only the first one - terraDraw.removeFeatures( - features - .slice(1) - .map((f) => f.id) - .filter((id): id is string | number => id !== undefined), - ); + if (!feature && terraDraw) { + terraDraw.removeFeatures( + features + .slice(1) + .map((f) => f.id) + .filter((id): id is string | number => id !== undefined), + ); + } return; } + // If a feature is provided, add it to the TerraDraw instance + if (feature) { + terraDraw.addFeatures([features[0]] as GeoJSONStoreFeatures[]); + } setOfflinePredictionAOI(features[0]); showSuccessToast("AOI drawn successfully."); + }, + [terraDraw, tileJSONMetadata], + ); + + /** + * Effect to handle the completion of drawing in TerraDraw. + * It listens for changes in the TerraDraw instance and updates the drawn feature state. + * If a feature is drawn, it clears previous features except the last one. + */ + useEffect(() => { + if (!terraDraw) return; + + const onFinish = () => { + handleDrawFinish(); }; - terraDraw.on("finish", handleDrawFinish); + terraDraw.on("finish", onFinish); return () => { - terraDraw.off("finish", handleDrawFinish); + terraDraw.off("finish", onFinish); }; - }, [terraDraw, tileJSONMetadata]); + }, [terraDraw, handleDrawFinish]); + /** + * Effect to set the current mode to OFFLINE if an AOI has been drawn. + * This is to ensure that the user can start offline predictions after drawing an AOI. + */ + useEffect(() => { + if (hasDrawnAOI && currentMode !== MapMode.OFFLINE) { + setCurrentMode(MapMode.OFFLINE); + } + }, [hasDrawnAOI, currentMode, setCurrentMode]); /** * Handle the drawing state change. * If the user starts drawing, set the current mode to OFFLINE. @@ -389,21 +453,6 @@ export const StartMappingPage = () => { [setCurrentMode], ); - /** - * Check if the user has drawn an AOI in offline mode. - */ - const hasDrawnAOI = useMemo(() => { - return currentMode === MapMode.OFFLINE && offlinePredictionAOI !== null; - }, [currentMode, offlinePredictionAOI]); - - /** - * Check if the offline prediction AOI is valid. - */ - const isOfflineMode = useMemo( - () => currentMode === MapMode.OFFLINE, - [currentMode], - ); - /** * Check if the model predictions exist. */ @@ -416,16 +465,16 @@ export const StartMappingPage = () => { () => [ ...(modelPredictions.length > 0 ? [ - { - value: - START_MAPPING_PAGE_CONTENT.map.controls.legendControl - .predictionResults, - subLayers: [ - ALL_MODEL_PREDICTIONS_FILL_LAYER_ID, - ALL_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, - ], - }, - ] + { + value: + START_MAPPING_PAGE_CONTENT.map.controls.legendControl + .predictionResults, + subLayers: [ + ALL_MODEL_PREDICTIONS_FILL_LAYER_ID, + ALL_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, + ], + }, + ] : []), ], [modelPredictions], @@ -442,18 +491,35 @@ export const StartMappingPage = () => { showSuccessToast(TOAST_NOTIFICATIONS.startMapping.fileDownloadSuccess); }, [modelPredictions, modelInfo]); + /** + * Handle the deletion of the AOI. + * It clears the TerraDraw instance and sets the offline prediction AOI state to null. + * It also shows a success toast message. + */ const handleAOIDelete = useCallback(() => { if (!terraDraw) return; terraDraw.clear(); setOfflinePredictionAOI(null); showSuccessToast("AOI cleared successfully."); }, [terraDraw]); + + /** + * Reset the offline prediction mode state. + * It sets the drawing mode to STATIC, clears the TerraDraw instance, + * and sets the current mode to ONLINE. + */ const resetOfflinePredictionModeState = useCallback(() => { setDrawingMode(DrawingModes.STATIC); setOfflinePredictionAOI(null); terraDraw?.clear(); setCurrentMode(MapMode.ONLINE); }, [setDrawingMode, setCurrentMode, terraDraw]); + + /** + * Handle the download of accepted features. + * It creates a GeoJSON file with the accepted features and triggers a download. + * It also shows a success toast message. + */ const handleAcceptedFeaturesDownload = useCallback(async () => { geoJSONDowloader( { type: "FeatureCollection", features: acceptedFeatures }, @@ -462,6 +528,10 @@ export const StartMappingPage = () => { showSuccessToast(TOAST_NOTIFICATIONS.startMapping.fileDownloadSuccess); }, [acceptedFeatures, modelInfo]); + /** + * Handle the download of features to JOSM. + * It opens the features in JOSM with the provided dataset name and source imagery. + */ const handleFeaturesDownloadToJOSM = useCallback( (features: Feature[]) => { if (!map || !modelInfo?.dataset) return; @@ -475,10 +545,18 @@ export const StartMappingPage = () => { [map, modelInfo], ); + /** + * Handle the download of all features to JOSM. + * It calls the handleFeaturesDownloadToJOSM function with the model predictions. + */ const handleAllFeaturesDownloadToJOSM = useCallback(() => { handleFeaturesDownloadToJOSM(modelPredictions); }, [handleFeaturesDownloadToJOSM, modelPredictions]); + /** + * Handle the download of accepted features to JOSM. + * It calls the handleFeaturesDownloadToJOSM function with the accepted features. + */ const handleAcceptedFeaturesDownloadToJOSM = useCallback(() => { handleFeaturesDownloadToJOSM(acceptedFeatures); }, [handleFeaturesDownloadToJOSM, acceptedFeatures]); @@ -525,6 +603,10 @@ export const StartMappingPage = () => { }, ]; + /** + * Handle the opening of the prediction imagery dialog. + * It closes the mobile drawer to prevent focus trapping issues with vaul. + */ const handlePredictionImageryDialogOpen = useCallback(() => { /** * Close the mobile drawer when the prediction imagery dialog is opened to prevent focus trapping issues with vaul. @@ -533,11 +615,19 @@ export const StartMappingPage = () => { openDialog(); }, [openDialog, setOpenMobileDrawer]); + /** + * Handle the closing of the prediction imagery dialog. + * It reopens the mobile drawer to allow the user to interact with it again. + */ const handlePredictionImageryDialogClose = useCallback(() => { setOpenMobileDrawer(true); closeDialog(); }, [closeDialog, setOpenMobileDrawer]); + /** + * Handle the opening of the prediction model dialog. + * It closes the mobile drawer to prevent focus trapping issues with vaul. + */ const handlePredictionModelDialogOpen = useCallback(() => { /** * Close the mobile drawer when the model selection dialog is opened to prevent focus trapping issues with vaul. @@ -546,6 +636,10 @@ export const StartMappingPage = () => { openModelSelectionDialog(); }, [openModelSelectionDialog, setOpenMobileDrawer]); + /** + * Handle the closing of the prediction model dialog. + * It reopens the mobile drawer to allow the user to interact with it again. + */ const handlePredictionModelDialogClose = useCallback(() => { setOpenMobileDrawer(true); closeModelSelectionDialog(); @@ -554,6 +648,29 @@ export const StartMappingPage = () => { return ( <> + { + handleDrawFinish({ + type: "Feature", + geometry: polygon, + id: uuid4(), + properties: { + mode: DrawingModes.POLYGON, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 200)); // Simulate a delay + closeFileUploadDialog(); + }} + disabled={false} + maxFiles={1} + buttonText="Add to Map" + /> { isOfflineMode={isOfflineMode} hasDrawnAOI={hasDrawnAOI} handleAOIDelete={handleAOIDelete} + openFileUploadDialog={openFileUploadDialog} />
diff --git a/frontend/src/components/layouts/navbar/user-profile.tsx b/frontend/src/components/layouts/navbar/user-profile.tsx index 481542f0e..bbbed6c0d 100644 --- a/frontend/src/components/layouts/navbar/user-profile.tsx +++ b/frontend/src/components/layouts/navbar/user-profile.tsx @@ -44,6 +44,12 @@ export const UserProfile = ({ navigate(APPLICATION_ROUTES.PROFILE_MODELS); }, }, + { + value: SHARED_CONTENT.navbar.userProfile.offlinePredictions, + onClick: () => { + navigate(APPLICATION_ROUTES.PROFILE_OFFLINE_PREDICTIONS); + }, + }, { value: SHARED_CONTENT.navbar.userProfile.settings, onClick: () => { diff --git a/frontend/src/components/map/map.tsx b/frontend/src/components/map/map.tsx index 969a22a10..9baeed631 100644 --- a/frontend/src/components/map/map.tsx +++ b/frontend/src/components/map/map.tsx @@ -64,10 +64,11 @@ export const MapComponent: React.FC = ({ {map ? ( <>
{zoomControls ? : null} {geolocationControl && } diff --git a/frontend/src/components/map/setups/setup-terra-draw.ts b/frontend/src/components/map/setups/setup-terra-draw.ts index 5c551c30e..5d39414a9 100644 --- a/frontend/src/components/map/setups/setup-terra-draw.ts +++ b/frontend/src/components/map/setups/setup-terra-draw.ts @@ -20,7 +20,7 @@ export const setupTerraDraw = (map: maplibregl.Map) => { tracked: true, adapter: new TerraDrawMapLibreGLAdapter({ map, - coordinatePrecision: 16, + coordinatePrecision: 20, }), // idStrategy: { // isValidId: () => true, diff --git a/frontend/src/features/model-creation/components/dialogs/file-upload-dialog.tsx b/frontend/src/components/shared/modals/file-upload-dialog.tsx similarity index 90% rename from frontend/src/features/model-creation/components/dialogs/file-upload-dialog.tsx rename to frontend/src/components/shared/modals/file-upload-dialog.tsx index a57e0747e..469ef1b04 100644 --- a/frontend/src/features/model-creation/components/dialogs/file-upload-dialog.tsx +++ b/frontend/src/components/shared/modals/file-upload-dialog.tsx @@ -33,6 +33,9 @@ type FileUploadDialogProps = DialogProps & { disableFileSizeValidation?: boolean; isAOILabelsUpload?: boolean; rawFileUploadHandler?: (formData: FormData) => Promise; + maxFiles?: number; + buttonText?: string; + additionalInstruction?: string; }; const isPolygonGeometry = ( @@ -57,6 +60,9 @@ const FileUploadDialog: React.FC = ({ // AOI labels are uploaded as raw GeoJSON file isAOILabelsUpload = false, rawFileUploadHandler, + maxFiles = MAX_TRAINING_AREA_UPLOAD_FILE_SIZE, + buttonText = MODELS_CONTENT.modelCreation.trainingArea.form.upload, + additionalInstruction, }) => { const [acceptedFiles, setAcceptedFiles] = useState([]); const [uploadInProgress, setUploadInProgress] = useState(false); @@ -74,7 +80,7 @@ const FileUploadDialog: React.FC = ({ if (file.size > MAX_TRAINING_AREA_UPLOAD_FILE_SIZE) { showErrorToast( undefined, - `File ${file.name} is too large (max ${formatAreaInAppropriateUnit(MAX_TRAINING_AREA_UPLOAD_FILE_SIZE)})`, + `File ${file.name} is too large (max ${MAX_TRAINING_AREA_UPLOAD_FILE_SIZE / 1024 / 1024} MB)`, ); return false; } @@ -91,13 +97,10 @@ const FileUploadDialog: React.FC = ({ if (geojson.type === "FeatureCollection") { if (!isAOILabelsUpload) { // Validate the number of features in the collection. - if ( - geojson.features.length > - MAX_ACCEPTABLE_POLYGON_IN_TRAINING_AREA_GEOJSON_FILE - ) { + if (geojson.features.length > maxFiles) { showErrorToast( undefined, - `File ${file.name} exceeds limit of ${MAX_ACCEPTABLE_POLYGON_IN_TRAINING_AREA_GEOJSON_FILE} polygon features.`, + `File ${file.name} exceeds limit of ${maxFiles} polygon features.`, ); continue; } @@ -136,6 +139,7 @@ const FileUploadDialog: React.FC = ({ accept: { "application/json": [".geojson", ".json"], }, + maxFiles: isAOILabelsUpload ? MAX_GEOJSON_FILE_UPLOAD_FOR_TRAINING_AREA_LABELS : MAX_GEOJSON_FILE_UPLOAD_FOR_TRAINING_AREAS, @@ -179,7 +183,7 @@ const FileUploadDialog: React.FC = ({ ) { showErrorToast( undefined, - `File area for ${file.name} exceeds area limit.`, + `File area for ${file.name} does not satisfy size limit.`, ); continue; } @@ -307,11 +311,25 @@ const FileUploadDialog: React.FC = ({ .fleSizeInstruction } + + {!disableFileSizeValidation && ( + + {`Max file size: ${formatAreaInAppropriateUnit( + MAX_TRAINING_AREA_UPLOAD_FILE_SIZE, + )}.`} + + )} + {!disableFileSizeValidation && ( {`Area should be > ${formatAreaInAppropriateUnit(MIN_TRAINING_AREA_SIZE)} and < ${formatAreaInAppropriateUnit(MAX_TRAINING_AREA_SIZE)}.`} )} + {additionalInstruction && ( + + {additionalInstruction} + + )} )}
@@ -333,7 +351,7 @@ const FileUploadDialog: React.FC = ({ ) : ( - MODELS_CONTENT.modelCreation.trainingArea.form.upload + buttonText )}
diff --git a/frontend/src/components/shared/model-explorer.tsx b/frontend/src/components/shared/model-explorer.tsx index 9c4d75bd5..e41e1cb9f 100644 --- a/frontend/src/components/shared/model-explorer.tsx +++ b/frontend/src/components/shared/model-explorer.tsx @@ -34,7 +34,8 @@ export const ModelExplorer = ({ createButtonAlt, userId, datasetId, - disableStatusFilter, status + disableStatusFilter, + status, }: { disableCreateNewButton?: boolean; title?: string; diff --git a/frontend/src/components/ui/icons/file-upload-icon.tsx b/frontend/src/components/ui/icons/file-upload-icon.tsx new file mode 100644 index 000000000..fe56a27e6 --- /dev/null +++ b/frontend/src/components/ui/icons/file-upload-icon.tsx @@ -0,0 +1,16 @@ +import { IconProps } from "@/types"; +import React from "react"; + +export const FileUploadIcon: React.FC = (props) => ( + + + +); diff --git a/frontend/src/components/ui/icons/index.ts b/frontend/src/components/ui/icons/index.ts index c6108152b..3066a248e 100644 --- a/frontend/src/components/ui/icons/index.ts +++ b/frontend/src/components/ui/icons/index.ts @@ -62,3 +62,4 @@ export { PeopleIcon } from "./people-icon"; export { ResetIcon } from "./reset-icon"; export { DirectionIcon } from "./direction-icon"; export { CloseIcon } from "./close-icon"; +export { FileUploadIcon } from "./file-upload-icon"; diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts index 64e39b613..824068709 100644 --- a/frontend/src/config/index.ts +++ b/frontend/src/config/index.ts @@ -162,11 +162,11 @@ export const MIN_TRAINING_AREA_SIZE: number = parseIntEnv( /** * The maximum file size (in bytes) allowed for training area upload. - * The default is set to 5 MB. + * The default is set to 1 MB. */ export const MAX_TRAINING_AREA_UPLOAD_FILE_SIZE: number = parseIntEnv( ENVS.MAX_TRAINING_AREA_UPLOAD_FILE_SIZE, - 5 * 1024 * 1024, + 1 * 1024 * 1024, ); /** diff --git a/frontend/src/constants/ui-contents/models-content.ts b/frontend/src/constants/ui-contents/models-content.ts index b75ce0469..24c9c3cfe 100644 --- a/frontend/src/constants/ui-contents/models-content.ts +++ b/frontend/src/constants/ui-contents/models-content.ts @@ -117,7 +117,7 @@ export const MODELS_CONTENT: TModelsContent = { mainInstruction: "Drag 'n' drop some files here, or click to select files", fleSizeInstruction: - "Supports only GeoJSON (.geojson) files. (5MB max.)", + "Supports only GeoJSON (.geojson) files. (1MB max.)", }, pageDescription: "Make sure you create at least one training area and data is accurate for each training area", diff --git a/frontend/src/constants/ui-contents/shared-content.ts b/frontend/src/constants/ui-contents/shared-content.ts index 849fb159d..d6599d254 100644 --- a/frontend/src/constants/ui-contents/shared-content.ts +++ b/frontend/src/constants/ui-contents/shared-content.ts @@ -19,6 +19,7 @@ export const SHARED_CONTENT: TSharedContent = { models: "My Models", settings: "Settings", logout: "Log Out", + offlinePredictions: "Offline Predictions", }, }, footer: { 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 d33b3bf32..fed1e02f4 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 @@ -1,4 +1,4 @@ -import FileUploadDialog from "@/features/model-creation/components/dialogs/file-upload-dialog"; +import FileUploadDialog from "@/components/shared/modals/file-upload-dialog"; import { DropDown } from "@/components/ui/dropdown"; import { IconProps, SlDropdownType, TTrainingAreaFeature } from "@/types"; import { JOSMLogo, OSMLogo } from "@/assets/svgs"; diff --git a/frontend/src/features/model-creation/components/training-area/training-area-map.tsx b/frontend/src/features/model-creation/components/training-area/training-area-map.tsx index 4325aa93a..ba5ac6696 100644 --- a/frontend/src/features/model-creation/components/training-area/training-area-map.tsx +++ b/frontend/src/features/model-creation/components/training-area/training-area-map.tsx @@ -208,7 +208,6 @@ const TrainingAreaMap = ({ return ( 0 ? [ - { - value: "Training Labels", - subLayers: [ - trainingAreasLabelsFillLayerId, - trainingAreasLabelsOutlineLayerId, - ], - }, - ] + { + value: "Training Labels", + subLayers: [ + trainingAreasLabelsFillLayerId, + trainingAreasLabelsOutlineLayerId, + ], + }, + ] : []), ...(data?.results?.features?.length ? [ - { - value: "Training Areas", - subLayers: [ - trainingAreasOutlineLayerId, - trainingAreasFillLayerId, - ], - }, - ] + { + value: "Training Areas", + subLayers: [ + trainingAreasOutlineLayerId, + trainingAreasFillLayerId, + ], + }, + ] : []), ]} > @@ -253,7 +252,7 @@ const TrainingAreaMap = ({ )} {!trainingAreasLabelsIsPending && - currentZoom >= MIN_ZOOM_LEVEL_FOR_TRAINING_AREA_LABELS ? ( + currentZoom >= MIN_ZOOM_LEVEL_FOR_TRAINING_AREA_LABELS ? ( void; - query: TQueryParams; - updateQuery: (newParams: TQueryParams) => void; - drawnAOI: Feature | null; - modelInfo: TModelDetails; - tileServerURL: string | undefined; - predictionModelCheckpoint: string; - resetOfflinePredictionModeState: () => void; + isOpen: boolean; + onClose: () => void; + query: TQueryParams; + updateQuery: (newParams: TQueryParams) => void; + drawnAOI: Feature | null; + modelInfo: TModelDetails; + tileServerURL: string | undefined; + predictionModelCheckpoint: string; + resetOfflinePredictionModeState: () => void; }) => { - const { modelId } = useParams(); - const [predictionRequestName, setPredictionRequestName] = - useState(""); - const [zoomLevel, setZoomLevel] = useState("18"); - const { isOpened, openDialog, closeDialog } = useDialog(); - const modelPredictionMutation = useSubmitOfflinePredictionsRequest({ - mutationConfig: { - onSuccess: () => { - // show success dialog - openDialog(); - // reset state - resetOfflinePredictionModeState(); - setPredictionRequestName(""); - setZoomLevel("18"); - onClose(); - }, - onError: (error) => showErrorToast(error), - }, - }); + const { modelId } = useParams(); + const [predictionRequestName, setPredictionRequestName] = + useState(""); + const [zoomLevel, setZoomLevel] = useState("18"); + const { isOpened, openDialog, closeDialog } = useDialog(); + const modelPredictionMutation = useSubmitOfflinePredictionsRequest({ + mutationConfig: { + onSuccess: () => { + // show success dialog + openDialog(); + // reset state + resetOfflinePredictionModeState(); + setPredictionRequestName(""); + setZoomLevel("18"); + onClose(); + }, + onError: (error) => showErrorToast(error), + }, + }); - return ( - <> - + + + + + Set the parameters for your prediction request. Selected model and + imagery will be used to generate predictions for the selected zoom + level. You can also set advanced settings for your prediction + request. + + + +
+ setPredictionRequestName(e.target.value)} + value={predictionRequestName} + toolTipContent={ + "Set a name for your prediction request. This will help you identify it later." + } + label={"Prediction Request Name"} + labelWithTooltip + placeholder={"Enter a name for your prediction request"} + showBorder + maxLength={MAXIMUM_PREDICTION_NAME_LENGTH} + minLength={MINIMUM_PREDICTION_NAME_LENGTH} + /> + +
+ + setZoomLevel(selection)} + /> +
+
+ - + +
+
+
+ - -
-
- - - ); + Cancel + + +
+ + + + ); }; diff --git a/frontend/src/features/start-mapping/components/dialogs/offline-prediction-request-success-dialog.tsx b/frontend/src/features/start-mapping/components/dialogs/offline-prediction-request-success-dialog.tsx index 7aecfc669..8e2a74565 100644 --- a/frontend/src/features/start-mapping/components/dialogs/offline-prediction-request-success-dialog.tsx +++ b/frontend/src/features/start-mapping/components/dialogs/offline-prediction-request-success-dialog.tsx @@ -7,44 +7,44 @@ import { ButtonVariant } from "@/enums/common"; import { useNavigate } from "react-router-dom"; export const OfflinePredictionRequestSuccess = ({ - isOpen, - onClose, + isOpen, + onClose, }: { - isOpen: boolean; - onClose: () => void; + isOpen: boolean; + onClose: () => void; }) => { - const navigate = useNavigate(); + const navigate = useNavigate(); - return ( - -
-
- Success Icon -
-

Prediction Request Sent

-

- We have received the request to run prediction on your specified area. - You will be notified when the prediction is done. -

-
- - -
-
-
- ); + return ( + +
+
+ Success Icon +
+

Prediction Request Sent

+

+ We have received the request to run prediction on your specified area. + You will be notified when the prediction is done. +

+
+ + +
+
+
+ ); }; diff --git a/frontend/src/features/start-mapping/components/map/map.tsx b/frontend/src/features/start-mapping/components/map/map.tsx index d5222dd0a..defc68967 100644 --- a/frontend/src/features/start-mapping/components/map/map.tsx +++ b/frontend/src/features/start-mapping/components/map/map.tsx @@ -1,5 +1,10 @@ import useScreenSize from "@/hooks/use-screen-size"; -import { ControlsPosition, DrawingModes, TileServiceType } from "@/enums"; +import { + ControlsPosition, + DrawingModes, + TileServiceType, + ToolTipPlacement, +} from "@/enums"; import { LngLatBoundsLike, Map } from "maplibre-gl"; import { Legend, @@ -28,6 +33,8 @@ import { } from "@/utils"; import { useInitialHashFit } from "@/hooks/use-map-hash-sync"; import { TerraDraw } from "terra-draw"; +import { ToolTip } from "@/components/ui/tooltip"; +import { FileUploadIcon } from "@/components/ui/icons"; export const StartMappingMapComponent = ({ map, @@ -48,6 +55,7 @@ export const StartMappingMapComponent = ({ isOfflineMode, hasDrawnAOI, handleAOIDelete, + openFileUploadDialog, }: { trainingId: number; map: Map | null; @@ -74,6 +82,7 @@ export const StartMappingMapComponent = ({ isOfflineMode: boolean; hasDrawnAOI?: boolean; handleAOIDelete?: () => void; + openFileUploadDialog?: () => void; }) => { const { isSmallViewport } = useScreenSize(); const currentZoom = useMapStore.getState().zoom; @@ -206,8 +215,8 @@ export const StartMappingMapComponent = ({ /> )} {memoizedToolTip} -
- {terraDraw && ( +
+ {terraDraw && map && ( )}
+ {terraDraw && map && ( +
+ + + +
+ )} {modelPredictionsExist && !isSmallViewport && } ); diff --git a/frontend/src/hooks/use-map-instance.ts b/frontend/src/hooks/use-map-instance.ts index a6b993d54..42a66a12b 100644 --- a/frontend/src/hooks/use-map-instance.ts +++ b/frontend/src/hooks/use-map-instance.ts @@ -38,9 +38,9 @@ export const useMapInstance = ( const terraDraw = useMemo(() => { if (map) { - const terraDraw = setupTerraDraw(map); - terraDraw.start(); - return terraDraw; + const draw = setupTerraDraw(map); + draw.start(); + return draw; } }, [map]); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index ec71a9aca..42f330cdb 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -3,7 +3,6 @@ import { BBOX } from "./common"; import { GeoJsonProperties, Geometry } from "geojson"; import { PredictedFeatureStatus } from "@/enums/start-mapping"; - /** * This file contains the different types/schema for the API responses from the backend. */ @@ -200,11 +199,12 @@ export type TTrainingFeedbacks = { export type Feature = { type: "Feature"; geometry: Geometry; + id?: string | number; properties: - | { - mid: string; - } - | GeoJsonProperties; + | { + mid: string; + } + | GeoJsonProperties; }; export type FeatureCollection = { diff --git a/frontend/src/types/ui-contents.ts b/frontend/src/types/ui-contents.ts index f2e30b0f6..c82f67006 100644 --- a/frontend/src/types/ui-contents.ts +++ b/frontend/src/types/ui-contents.ts @@ -327,6 +327,7 @@ export type TSharedContent = { models: string; settings: string; logout: string; + offlinePredictions: string; }; }; footer: { diff --git a/frontend/src/utils/geo/geo-utils.ts b/frontend/src/utils/geo/geo-utils.ts index b80d5b3cc..8b9104552 100644 --- a/frontend/src/utils/geo/geo-utils.ts +++ b/frontend/src/utils/geo/geo-utils.ts @@ -68,6 +68,10 @@ export const openInIDEditor = ( export const validateGeoJSONArea = (geojsonFeature: Feature) => { const area = calculateGeoJSONArea(geojsonFeature); + + if (!area || isNaN(area) || area === Infinity || area === 0) { + return false; + } return area < MIN_TRAINING_AREA_SIZE || area > MAX_TRAINING_AREA_SIZE; }; diff --git a/frontend/src/utils/geo/geometry-utils.ts b/frontend/src/utils/geo/geometry-utils.ts index 760463a97..40fc7847c 100644 --- a/frontend/src/utils/geo/geometry-utils.ts +++ b/frontend/src/utils/geo/geometry-utils.ts @@ -38,15 +38,14 @@ export const calculateGeoJSONArea = ( */ export function formatAreaInAppropriateUnit(area: number) { - const SQUARE_METERS_IN_SQUARE_KILOMETER = 1000000; - if (area > SQUARE_METERS_IN_SQUARE_KILOMETER) { - return ( - roundNumber( - area / SQUARE_METERS_IN_SQUARE_KILOMETER, - 1, - ).toLocaleString() + "km²" - ); + const KM2_THRESHOLD = 100_000; // 0.1 km² + const SQUARE_METERS_IN_SQUARE_KILOMETER = 1_000_000; + + if (area >= KM2_THRESHOLD) { + const areaInKm2 = area / SQUARE_METERS_IN_SQUARE_KILOMETER; + return roundNumber(areaInKm2, 2).toLocaleString() + "km²"; } + return roundNumber(area, 1).toLocaleString() + "m²"; } From 650141068c0f44dc14ebd9cf8374131ae2149faa Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Wed, 25 Jun 2025 10:47:33 +0200 Subject: [PATCH 04/19] feat: finalized drawing flow + updated results dashboard --- .../routes/profile/offline-predictions.tsx | 60 +++- .../shared/modals/file-upload-dialog.tsx | 4 +- .../datasets/hooks/use-query-params.ts | 14 +- .../src/features/user-profile/api/factory.ts | 15 + .../user-profile/api/get-predictions.ts | 32 +++ .../components/offline-predictions-table.tsx | 265 ++++++++++++++++++ .../user-profile/hooks/use-predictions.ts | 111 ++++++++ frontend/src/services/api-routes.ts | 2 + frontend/src/types/api.ts | 16 +- 9 files changed, 510 insertions(+), 9 deletions(-) create mode 100644 frontend/src/features/user-profile/api/factory.ts create mode 100644 frontend/src/features/user-profile/api/get-predictions.ts create mode 100644 frontend/src/features/user-profile/components/offline-predictions-table.tsx create mode 100644 frontend/src/features/user-profile/hooks/use-predictions.ts diff --git a/frontend/src/app/routes/profile/offline-predictions.tsx b/frontend/src/app/routes/profile/offline-predictions.tsx index 326c24b50..8fa8f0a20 100644 --- a/frontend/src/app/routes/profile/offline-predictions.tsx +++ b/frontend/src/app/routes/profile/offline-predictions.tsx @@ -1,5 +1,61 @@ -import { PageUnderConstruction } from "@/components/errors"; +import { useAuth } from "@/app/providers/auth-provider"; +import { Head } from "@/components/seo"; +import { OrderingFilter, Pagination, SearchFilter } from "@/components/shared"; +import { ProfileSectionHeader } from "@/features/user-profile/components"; +import OfflinePredictionsTable from "@/features/user-profile/components/offline-predictions-table"; +import { useOfflinePredictionsQueryParams } from "@/features/user-profile/hooks/use-predictions"; export const UserProfileOfflinePredictionsPage = () => { - return ; + const { user } = useAuth(); + const { data, isError, isPending, isPlaceholderData, query, updateQuery } = + useOfflinePredictionsQueryParams(user.osm_id); + + return ( + <> + +
+ {/* Section heading */} +
+ + +
+
+

+ {data?.count} prediction{data?.count && data?.count > 1 ? "s" : ""} +

+
+ +
+ +
+
+
+ +
+ + ); }; diff --git a/frontend/src/components/shared/modals/file-upload-dialog.tsx b/frontend/src/components/shared/modals/file-upload-dialog.tsx index 469ef1b04..53fddaf34 100644 --- a/frontend/src/components/shared/modals/file-upload-dialog.tsx +++ b/frontend/src/components/shared/modals/file-upload-dialog.tsx @@ -9,7 +9,7 @@ import { SlFormatBytes } from "@shoelace-style/shoelace/dist/react"; import { Spinner } from "@/components/ui/spinner"; import { useCallback, useState } from "react"; import { - MAX_ACCEPTABLE_POLYGON_IN_TRAINING_AREA_GEOJSON_FILE, + MAX_GEOJSON_FILE_UPLOAD_FOR_TRAINING_AREA_LABELS, MAX_GEOJSON_FILE_UPLOAD_FOR_TRAINING_AREAS, MAX_TRAINING_AREA_SIZE, @@ -147,7 +147,7 @@ const FileUploadDialog: React.FC = ({ disabled || uploadInProgress || acceptedFiles.length === - MAX_GEOJSON_FILE_UPLOAD_FOR_TRAINING_AREA_LABELS || + MAX_GEOJSON_FILE_UPLOAD_FOR_TRAINING_AREA_LABELS || acceptedFiles.length === MAX_GEOJSON_FILE_UPLOAD_FOR_TRAINING_AREAS, }); diff --git a/frontend/src/features/datasets/hooks/use-query-params.ts b/frontend/src/features/datasets/hooks/use-query-params.ts index fc8e2414d..496d743ad 100644 --- a/frontend/src/features/datasets/hooks/use-query-params.ts +++ b/frontend/src/features/datasets/hooks/use-query-params.ts @@ -63,13 +63,19 @@ export const useDatasetsQueryParams = (userId?: number) => { //reset offset back to 0 when searching or when ID filtering is applied from the map. useEffect(() => { if ( - query[SEARCH_PARAMS.searchQuery] !== "" || - (query[SEARCH_PARAMS.id] !== "" && - (query[SEARCH_PARAMS.offset] as number) > 0) + (query[SEARCH_PARAMS.searchQuery] !== "" || + query[SEARCH_PARAMS.id] !== "") && + (query[SEARCH_PARAMS.offset] as number) > 0 ) { updateQuery({ [SEARCH_PARAMS.offset]: 0 }); } - }, [query]); + }, [ + [ + query[SEARCH_PARAMS.searchQuery], + query[SEARCH_PARAMS.offset], + query[SEARCH_PARAMS.id], + ], + ]); useEffect(() => { const newQuery = { diff --git a/frontend/src/features/user-profile/api/factory.ts b/frontend/src/features/user-profile/api/factory.ts new file mode 100644 index 000000000..e5a946cc6 --- /dev/null +++ b/frontend/src/features/user-profile/api/factory.ts @@ -0,0 +1,15 @@ +import { queryOptions } from "@tanstack/react-query"; +import { getPredictions } from "./get-predictions"; + +export const getPredictionsQueryOptions = ( + searchQuery?: string, + ordering?: string, + userId?: number, + offset?: number, +) => { + return queryOptions({ + queryKey: ["offline-predictions", searchQuery, ordering, userId, offset], + queryFn: () => getPredictions(searchQuery, ordering, userId, offset), + refetchInterval: 10000, // 10 seconds + }); +}; diff --git a/frontend/src/features/user-profile/api/get-predictions.ts b/frontend/src/features/user-profile/api/get-predictions.ts new file mode 100644 index 000000000..ac6efa4a1 --- /dev/null +++ b/frontend/src/features/user-profile/api/get-predictions.ts @@ -0,0 +1,32 @@ +import { PAGE_LIMIT } from "@/components/shared"; +import { API_ENDPOINTS, apiClient } from "@/services"; +import { TOfflinePrediction } from "@/types"; + +export const getPredictions = async ( + searchQuery?: string, + ordering: string = "-id", + userId?: number, + offset?: number, +): Promise<{ + count: number; + next: string | null; + previous: string | null; + results: TOfflinePrediction[]; + hasNext: boolean; + hasPrev: boolean; +}> => { + const res = await apiClient.get(API_ENDPOINTS.GET_OFFLINE_PREDICTIONS, { + params: { + search: searchQuery, + ordering, + user: userId, + offset, + limit: PAGE_LIMIT, + }, + }); + return { + ...res.data, + hasNext: res.data.next !== null, + hasPrev: res.data.previous !== null, + }; +}; diff --git a/frontend/src/features/user-profile/components/offline-predictions-table.tsx b/frontend/src/features/user-profile/components/offline-predictions-table.tsx new file mode 100644 index 000000000..8d9c4186f --- /dev/null +++ b/frontend/src/features/user-profile/components/offline-predictions-table.tsx @@ -0,0 +1,265 @@ +import { Badge } from "@/components/ui/badge"; +import { ColumnDef, SortingState } from "@tanstack/react-table"; +import { DataTable } from "@/components/ui/data-table"; + +import { SortableHeader } from "@/features/models/components/table-header"; +import { TableSkeleton } from "@/features/models/components/skeletons"; +import { TBadgeVariants, TOfflinePrediction } from "@/types"; +import { useState } from "react"; +import { + formatDate, + formatDuration, + showSuccessToast, + showWarningToast, + truncateString, +} from "@/utils"; +import { DropDown } from "@/components/ui/dropdown"; +import { ElipsisIcon, InfoIcon } from "@/components/ui/icons"; +import { ModelTrainingStatus } from "@/enums"; +import useCopyToClipboard from "@/hooks/use-clipboard"; +import { API_ENDPOINTS } from "@/services"; +import { BASE_API_URL } from "@/config"; + +type OfflinePredictionsTableProps = { + data: TOfflinePrediction[]; + isError: boolean; + isPending: boolean; +}; + +const columnDefinitions = (): ColumnDef[] => [ + { + accessorKey: "id", + header: ({ column }) => , + }, + { + header: "Prediction Name", + accessorFn: (row) => + row.description && row.description.length > 0 + ? truncateString(row.description) + : "-", + }, + + { + accessorKey: "created_at", + accessorFn: (row) => + row.created_at !== null ? formatDate(row.created_at) : "-", + header: "Date Submitted", + cell: (row) => { + return {row.getValue() as string}; + }, + }, + { + accessorFn: (row) => row.config.zoom_level, + header: "Zoom Level", + cell: (row) => { + return {row.getValue() as string}; + }, + }, + { + header: "Status", + accessorKey: "status", + cell: (row) => { + const statusToVariant: Record = { + finished: "green", + failed: "red", + submitted: "blue", + running: "yellow", + }; + + return ( + + {String(row.getValue()).toLocaleLowerCase() as string} + + ); + }, + }, + { + header: "Duration", + accessorFn: (row) => + row.finished_at && row.started_at + ? formatDuration(new Date(row.started_at), new Date(row.finished_at)) + : "-", + cell: (row) => ( + {row.getValue() as string} + ), + }, + { + header: "Info", + cell: ({ row }: { row: any }) => { + return ( + { + // Prevent the row click event from firing + e.stopPropagation(); + }} + className="rounded-lg px-2 items-center flex" + > + + + } + className="text-right" + distance={10} + > +
+

+ Settings Info +

+ {Object.entries(row.original.config) + .filter( + ([key]) => + key !== "checkpoint" && key !== "source" && key !== "bbox", + ) + .map(([key, value]) => ( + + {key + .replace(/_/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase())} + :{" "} + + {typeof value === "boolean" + ? value + ? "True" + : "False" + : String(value)} + + + ))} +
+
+ ); + }, + }, + { + header: "Actions", + cell: ({ row }: { row: any }) => { + const { copyToClipboard } = useCopyToClipboard(); + return ( + { + // Prevent the row click event from firing + e.stopPropagation(); + }} + className="rounded-lg px-2 items-center flex" + > + + + } + className="text-right" + distance={10} + menuItems={[ + { + name: "Download results", + value: "Download results", + onClick: (e) => { + // Prevent the row click event from firing + e.stopPropagation(); + // publishTraining(row.getValue("id")); + }, + disabled: row.getValue("status") !== ModelTrainingStatus.FINISHED, + }, + { + name: "View results", + value: "View results", + onClick: (e) => { + // Prevent the row click event from firing + e.stopPropagation(); + // terminationMutation(row.getValue("id")); + }, + disabled: row.getValue("status") !== ModelTrainingStatus.FINISHED, + }, + { + name: "Copy results link", + value: "Copy results link", + onClick: async (e) => { + // Prevent the row click event from firing + e.stopPropagation(); + await copyToClipboard( + BASE_API_URL + + API_ENDPOINTS.GET_PREDICTIONS_TASK_STATUS( + row.original.task_id, + ), + ); + showSuccessToast("Copied results link to clipboard"); + }, + disabled: row.getValue("status") !== ModelTrainingStatus.FINISHED, + }, + { + name: "Create MapSwipe project", + value: "Create MapSwipe project", + onClick: (e) => { + // Prevent the row click event from firing + e.stopPropagation(); + // terminationMutation(row.getValue("id")); + }, + disabled: row.getValue("status") !== ModelTrainingStatus.FINISHED, + }, + { + name: "View logs", + value: "View logs", + disabled: row.getValue("status") !== ModelTrainingStatus.FAILED, + onClick: (e) => { + // Prevent the row click event from firing + e.stopPropagation(); + // handleTrainingModal(row.getValue("id") as number); + showWarningToast( + `Can't view logs for this prediction at this time.`, + ); + }, + }, + { + name: "Copy imagery link", + value: "Copy imagery link", + onClick: async (e) => { + e.stopPropagation(); + await copyToClipboard(row.original.config.source); + showSuccessToast("Copied imagery link to clipboard"); + }, + }, + ]} + /> + ); + }, + }, +]; + +const OfflinePredictionsTable: React.FC = ({ + data, + isPending, + isError, +}) => { + const [sorting, setSorting] = useState([]); + if (isPending || isError) return ; + + return ( + <> +
+ +
+ + ); +}; + +export default OfflinePredictionsTable; diff --git a/frontend/src/features/user-profile/hooks/use-predictions.ts b/frontend/src/features/user-profile/hooks/use-predictions.ts new file mode 100644 index 000000000..d6fd6c9e0 --- /dev/null +++ b/frontend/src/features/user-profile/hooks/use-predictions.ts @@ -0,0 +1,111 @@ +import useDebounce from "@/hooks/use-debounce"; +import { TQueryParams } from "@/types"; +import { useCallback, useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { ORDERING_FIELDS } from "@/components/shared/filters/ordering-filter"; +import { SEARCH_PARAMS } from "@/utils/search-params"; +import { useQuery } from "@tanstack/react-query"; +import { getPredictionsQueryOptions } from "../api/factory"; + +export const useGetPredictions = ( + searchQuery?: string, + ordering?: string, + userId?: number, + offset?: number, +) => { + return useQuery({ + ...getPredictionsQueryOptions(searchQuery, ordering, userId, offset), + }); +}; + +export const useOfflinePredictionsQueryParams = (userId?: number) => { + const [searchParams, setSearchParams] = useSearchParams(); + + const defaultQueries = { + [SEARCH_PARAMS.offset]: 0, + [SEARCH_PARAMS.searchQuery]: + searchParams.get(SEARCH_PARAMS.searchQuery) || "", + [SEARCH_PARAMS.ordering]: + searchParams.get(SEARCH_PARAMS.ordering) || + (ORDERING_FIELDS[1].apiValue as string), + }; + + const [query, setQuery] = useState(defaultQueries); + + const debouncedSearchText = useDebounce( + query[SEARCH_PARAMS.searchQuery] as string, + 300, + ); + + const { isPending, isError, data, refetch, isPlaceholderData } = + useGetPredictions( + debouncedSearchText.length > 0 ? debouncedSearchText : undefined, + query[SEARCH_PARAMS.ordering] as string, + userId !== undefined ? userId : undefined, + query[SEARCH_PARAMS.offset] !== undefined + ? (query[SEARCH_PARAMS.offset] as number) + : undefined, + ); + + const updateQuery = useCallback( + (newParams: TQueryParams) => { + setQuery((prevQuery) => ({ + ...prevQuery, + ...newParams, + })); + const updatedParams = new URLSearchParams(searchParams); + + Object.entries(newParams).forEach(([key, value]) => { + if (value) { + updatedParams.set(key, String(value)); + } else { + updatedParams.delete(key); + } + }); + + setSearchParams(updatedParams, { replace: true }); + }, + [searchParams, setSearchParams], + ); + + //reset offset back to 0 when searching. + useEffect(() => { + if ( + query[SEARCH_PARAMS.searchQuery] !== "" && + (query[SEARCH_PARAMS.offset] as number) > 0 + ) { + updateQuery({ [SEARCH_PARAMS.offset]: 0 }); + } + }, [[query[SEARCH_PARAMS.searchQuery], query[SEARCH_PARAMS.offset]]]); + + useEffect(() => { + const newQuery = { + [SEARCH_PARAMS.offset]: defaultQueries[SEARCH_PARAMS.offset], + [SEARCH_PARAMS.ordering]: defaultQueries[SEARCH_PARAMS.ordering], + [SEARCH_PARAMS.searchQuery]: defaultQueries[SEARCH_PARAMS.searchQuery], + }; + setQuery(newQuery); + }, []); + + const clearAllFilters = useCallback(() => { + const resetParams = new URLSearchParams(); + setSearchParams(resetParams); + setQuery((prev) => ({ + // Preserve existing query params + ...prev, + // Clear only the filter fields + [SEARCH_PARAMS.searchQuery]: "", + })); + }, []); + + return { + query, + data, + isPending, + isPlaceholderData, + isError, + updateQuery, + refetch, + clearAllFilters, + }; +}; diff --git a/frontend/src/services/api-routes.ts b/frontend/src/services/api-routes.ts index 089dd69c7..cac03f0bf 100644 --- a/frontend/src/services/api-routes.ts +++ b/frontend/src/services/api-routes.ts @@ -24,7 +24,9 @@ export const API_ENDPOINTS = { GET_MODEL_PREDICTIONS: FAIR_PREDICTOR_API_ENDPOINT, CREATE_OFFLINE_PREDICTION: "prediction/", + GET_OFFLINE_PREDICTIONS: "prediction/", + GET_PREDICTIONS_TASK_STATUS: (taskId: string) => `task/status/${taskId}`, // Feedbacks CREATE_FEEDBACK: "feedback/", diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 42f330cdb..893f31236 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -1,4 +1,4 @@ -import { BASE_MODELS } from "@/enums"; +import { BASE_MODELS, ModelTrainingStatus } from "@/enums"; import { BBOX } from "./common"; import { GeoJsonProperties, Geometry } from "geojson"; import { PredictedFeatureStatus } from "@/enums/start-mapping"; @@ -247,3 +247,17 @@ export type TModelPredictionFeature = { }; id?: string | number; }; + +export type TOfflinePrediction = { + id: number; + geom: Geometry; + description: string | null; + created_at: string; + started_at: string | null; + finished_at: string | null; + status: ModelTrainingStatus; + task_id: string; + mapswipe_id: string | null; + user: number; + config: TModelPredictionsConfig; +}; From d637ebd39489ee6c2005bc3f811dd78216b736ee Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Sat, 28 Jun 2025 22:05:58 +0200 Subject: [PATCH 05/19] feat: finalized offline prediction options --- .../routes/profile/offline-predictions.tsx | 2 +- .../shared/modals/file-upload-dialog.tsx | 3 +- .../src/components/shared/training-logs.tsx | 40 +++++++ .../components/filters/search-filter.tsx | 0 .../components/maps/training-area-map.tsx | 24 +++- .../components/model-details-properties.tsx | 35 +----- .../components/offline-predictions-table.tsx | 95 +++++++++++---- .../components/predictions-results-drawer.tsx | 109 ++++++++++++++++++ .../components/training-logs-dialog.tsx | 25 ++++ frontend/src/services/api-routes.ts | 8 +- .../__tests__/geo/geometry-utils.test.ts | 2 +- 11 files changed, 283 insertions(+), 60 deletions(-) create mode 100644 frontend/src/components/shared/training-logs.tsx delete mode 100644 frontend/src/features/datasets/components/filters/search-filter.tsx create mode 100644 frontend/src/features/user-profile/components/predictions-results-drawer.tsx create mode 100644 frontend/src/features/user-profile/components/training-logs-dialog.tsx diff --git a/frontend/src/app/routes/profile/offline-predictions.tsx b/frontend/src/app/routes/profile/offline-predictions.tsx index 8fa8f0a20..c8ffd1c3c 100644 --- a/frontend/src/app/routes/profile/offline-predictions.tsx +++ b/frontend/src/app/routes/profile/offline-predictions.tsx @@ -13,7 +13,7 @@ export const UserProfileOfflinePredictionsPage = () => { return ( <> -
+
{/* Section heading */}
diff --git a/frontend/src/components/shared/modals/file-upload-dialog.tsx b/frontend/src/components/shared/modals/file-upload-dialog.tsx index 53fddaf34..c82ead344 100644 --- a/frontend/src/components/shared/modals/file-upload-dialog.tsx +++ b/frontend/src/components/shared/modals/file-upload-dialog.tsx @@ -9,7 +9,6 @@ import { SlFormatBytes } from "@shoelace-style/shoelace/dist/react"; import { Spinner } from "@/components/ui/spinner"; import { useCallback, useState } from "react"; import { - MAX_GEOJSON_FILE_UPLOAD_FOR_TRAINING_AREA_LABELS, MAX_GEOJSON_FILE_UPLOAD_FOR_TRAINING_AREAS, MAX_TRAINING_AREA_SIZE, @@ -147,7 +146,7 @@ const FileUploadDialog: React.FC = ({ disabled || uploadInProgress || acceptedFiles.length === - MAX_GEOJSON_FILE_UPLOAD_FOR_TRAINING_AREA_LABELS || + MAX_GEOJSON_FILE_UPLOAD_FOR_TRAINING_AREA_LABELS || acceptedFiles.length === MAX_GEOJSON_FILE_UPLOAD_FOR_TRAINING_AREAS, }); diff --git a/frontend/src/components/shared/training-logs.tsx b/frontend/src/components/shared/training-logs.tsx new file mode 100644 index 000000000..e531dcf70 --- /dev/null +++ b/frontend/src/components/shared/training-logs.tsx @@ -0,0 +1,40 @@ +import { useTrainingStatus } from "@/features/models/hooks/use-training"; +import { useState } from "react"; +import { ChevronDownIcon } from "@/components/ui/icons"; +import { CodeBlock } from "@/components/ui/codeblock"; +import { MODELS_CONTENT } from "@/constants"; + +export const TrainingLogs = ({ + taskId, + expandByDefault = false, + disableExpandButton = false, +}: { + taskId: string; + expandByDefault?: boolean; + disableExpandButton?: boolean; +}) => { + const { data, isPending } = useTrainingStatus(taskId); + const [showLogs, setShowLogs] = useState(expandByDefault); + + if (isPending) { + return ( +
+ ); + } + return ( +
+ {!disableExpandButton && ( + + )} + {showLogs && } +
+ ); +}; diff --git a/frontend/src/features/datasets/components/filters/search-filter.tsx b/frontend/src/features/datasets/components/filters/search-filter.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/features/models/components/maps/training-area-map.tsx b/frontend/src/features/models/components/maps/training-area-map.tsx index 199051412..7a86c1db5 100644 --- a/frontend/src/features/models/components/maps/training-area-map.tsx +++ b/frontend/src/features/models/components/maps/training-area-map.tsx @@ -59,11 +59,13 @@ export const TrainingAreaMap = ({ trainingAreaId, tmsURL, visible, + isPredictionResult = false, }: { file: string; trainingAreaId: number; tmsURL: string; visible: boolean; + isPredictionResult?: boolean; }) => { const { mapContainerRef, map } = useMapInstance(true); @@ -76,7 +78,9 @@ export const TrainingAreaMap = ({ [0, 0], ]); - const trainingAreasSourceId = `training-areas-for-${trainingAreaId}`; + const trainingAreasSourceId = isPredictionResult + ? `prediction-results-for-${trainingAreaId}` + : `training-areas-for-${trainingAreaId}`; const mapLayers: LayerSpecification[] = vectorLayers.flatMap((layer) => { const { fill, outline } = getLayerConfigs(layer.id); @@ -111,11 +115,13 @@ export const TrainingAreaMap = ({ ]; const layerControlLayers = vectorLayers.map((layer) => ({ - value: `Training ${layer.id}`, + value: isPredictionResult ? "Prediction Results" : `Training ${layer.id}`, subLayers: [`${layer.id}_fill`, `${layer.id}_outline`], })); const fitToBounds = useCallback(() => { + if (!map) return; + if ( map && boundsRef.current[0][0] !== boundsRef.current[1][0] && @@ -215,6 +221,7 @@ export const TrainingAreaMap = ({ const metadata = (await pmtilesFile.getMetadata()) as Metadata; const layers = metadata.vector_layers; + setVectorLayers(layers); } catch (error) { console.error("Error loading PMTiles:", error); @@ -226,16 +233,29 @@ export const TrainingAreaMap = ({ useEffect(() => { if (!map) return; + map.on("click", handleMouseClick); return () => { + if (!map) return; map.off("click", handleMouseClick); }; }, [map, handleMouseClick]); useEffect(() => { if (!map) return; + if (!map.getStyle()) return; addSources(map, sources); addLayers(map, mapLayers); + return () => { + if (!map) return; + if (!map.getStyle()) return; + mapLayers.forEach((layer) => { + if (map.getLayer(layer.id)) { + map.removeLayer(layer.id); + } + }); + map.removeSource(trainingAreasSourceId); + }; }, [map, mapLayers]); useEffect(() => { diff --git a/frontend/src/features/models/components/model-details-properties.tsx b/frontend/src/features/models/components/model-details-properties.tsx index 0ff58503a..4988cd754 100644 --- a/frontend/src/features/models/components/model-details-properties.tsx +++ b/frontend/src/features/models/components/model-details-properties.tsx @@ -1,9 +1,7 @@ import AccuracyDisplay from "./accuracy-display"; -import CodeBlock from "@/components/ui/codeblock/codeblock"; import ModelFilesButton from "./model-files-button"; import ToolTip from "@/components/ui/tooltip/tooltip"; import { BASE_API_URL } from "@/config"; -import { ChevronDownIcon } from "@/components/ui/icons"; import { cn, showErrorToast } from "@/utils"; import { ExternalLinkIcon } from "@/components/ui/icons"; import { Image, ZoomableImage } from "@/components/ui/image"; @@ -14,12 +12,10 @@ import { MODELS_CONTENT } from "@/constants"; import { TrainingAreaButton } from "./training-area-button"; import { TrainingAreaDrawer } from "./training-area-drawer"; import { useDialog } from "@/hooks/use-dialog"; -import { useEffect, useState } from "react"; -import { - useTrainingDetails, - useTrainingStatus, -} from "@/features/models/hooks/use-training"; +import { useEffect } from "react"; +import { useTrainingDetails } from "@/features/models/hooks/use-training"; import { CopyButton } from "@/components/ui/copy-button"; +import { TrainingLogs } from "@/components/shared/training-logs"; enum TrainingStatus { FAILED = "FAILED", @@ -319,7 +315,7 @@ const ModelProperties: React.FC = ({ {isTrainingDetailsDialog && (data?.status === TrainingStatus.FAILED || data?.status === TrainingStatus.RUNNING) && ( - + )}
@@ -328,26 +324,3 @@ const ModelProperties: React.FC = ({ }; export default ModelProperties; - -const FailedTrainingTraceBack = ({ taskId }: { taskId: string }) => { - const { data, isPending } = useTrainingStatus(taskId); - const [showLogs, setShowLogs] = useState(false); - - if (isPending) { - return ( -
- ); - } - return ( -
- - {showLogs && } -
- ); -}; diff --git a/frontend/src/features/user-profile/components/offline-predictions-table.tsx b/frontend/src/features/user-profile/components/offline-predictions-table.tsx index 8d9c4186f..203b7ef50 100644 --- a/frontend/src/features/user-profile/components/offline-predictions-table.tsx +++ b/frontend/src/features/user-profile/components/offline-predictions-table.tsx @@ -15,10 +15,13 @@ import { } from "@/utils"; import { DropDown } from "@/components/ui/dropdown"; import { ElipsisIcon, InfoIcon } from "@/components/ui/icons"; -import { ModelTrainingStatus } from "@/enums"; +import { DropdownPlacement, ModelTrainingStatus } from "@/enums"; import useCopyToClipboard from "@/hooks/use-clipboard"; import { API_ENDPOINTS } from "@/services"; import { BASE_API_URL } from "@/config"; +import { TrainingLogsDialog } from "./training-logs-dialog"; +import { useDialog } from "@/hooks/use-dialog"; +import { PredictionResultDrawer } from "./predictions-results-drawer"; type OfflinePredictionsTableProps = { data: TOfflinePrediction[]; @@ -26,7 +29,10 @@ type OfflinePredictionsTableProps = { isPending: boolean; }; -const columnDefinitions = (): ColumnDef[] => [ +const columnDefinitions = ( + handleTrainingLogsModal: (taskId: string) => void, + handlePredictionResultModal: (prediction: TOfflinePrediction) => void, +): ColumnDef[] => [ { accessorKey: "id", header: ({ column }) => , @@ -95,11 +101,11 @@ const columnDefinitions = (): ColumnDef[] => [ return ( { - // Prevent the row click event from firing e.stopPropagation(); }} className="rounded-lg px-2 items-center flex" @@ -168,9 +174,16 @@ const columnDefinitions = (): ColumnDef[] => [ name: "Download results", value: "Download results", onClick: (e) => { - // Prevent the row click event from firing e.stopPropagation(); - // publishTraining(row.getValue("id")); + const downloadUrl = + BASE_API_URL + + API_ENDPOINTS.DOWNLOAD_PREDICTION_LABELS_FILE( + row.original.id, + ); + // It's possible that the download file is large, so we open it in a new tab + // to avoid blocking the UI. + // This will allow the user to download the file without interrupting their workflow. + window.open(downloadUrl, "_blank"); }, disabled: row.getValue("status") !== ModelTrainingStatus.FINISHED, }, @@ -178,9 +191,8 @@ const columnDefinitions = (): ColumnDef[] => [ name: "View results", value: "View results", onClick: (e) => { - // Prevent the row click event from firing e.stopPropagation(); - // terminationMutation(row.getValue("id")); + handlePredictionResultModal(row.original); }, disabled: row.getValue("status") !== ModelTrainingStatus.FINISHED, }, @@ -188,39 +200,45 @@ const columnDefinitions = (): ColumnDef[] => [ name: "Copy results link", value: "Copy results link", onClick: async (e) => { - // Prevent the row click event from firing e.stopPropagation(); await copyToClipboard( BASE_API_URL + - API_ENDPOINTS.GET_PREDICTIONS_TASK_STATUS( - row.original.task_id, + API_ENDPOINTS.DOWNLOAD_PREDICTION_LABELS_FILE( + row.original.id, ), ); - showSuccessToast("Copied results link to clipboard"); + showSuccessToast("Copied results link to clipboard!"); }, disabled: row.getValue("status") !== ModelTrainingStatus.FINISHED, }, { - name: "Create MapSwipe project", - value: "Create MapSwipe project", + name: !row.original.mapswipe_id + ? "Create MapSwipe project" + : "View MapSwipe project", + value: !row.original.mapswipe_id + ? "Create MapSwipe project" + : "View MapSwipe project", onClick: (e) => { + // can be used to create or view a MapSwipe project // Prevent the row click event from firing e.stopPropagation(); - // terminationMutation(row.getValue("id")); + showWarningToast( + "This feature is not yet implemented. Please check back later.", + ); }, disabled: row.getValue("status") !== ModelTrainingStatus.FINISHED, }, { name: "View logs", value: "View logs", - disabled: row.getValue("status") !== ModelTrainingStatus.FAILED, + disabled: ![ + ModelTrainingStatus.FAILED, + ModelTrainingStatus.IN_PROGRESS, + ].includes(row.getValue("status")), onClick: (e) => { // Prevent the row click event from firing e.stopPropagation(); - // handleTrainingModal(row.getValue("id") as number); - showWarningToast( - `Can't view logs for this prediction at this time.`, - ); + handleTrainingLogsModal(row.original.task_id as string); }, }, { @@ -245,15 +263,50 @@ const OfflinePredictionsTable: React.FC = ({ isError, }) => { const [sorting, setSorting] = useState([]); + const { isOpened, openDialog, closeDialog } = useDialog(); + const { + isOpened: isPredictionResultOpened, + openDialog: openPredictionResultDialog, + closeDialog: closePredictionResultDialog, + } = useDialog(); + const [activeTaskId, setActiveTaskId] = useState(null); + const [activePrediction, setActivePrediction] = + useState(null); + const handleTrainingLogsModal = (taskId: string) => { + setActiveTaskId(taskId); + openDialog(); + }; + const handlePredictionResultModal = (prediction: TOfflinePrediction) => { + setActivePrediction(prediction); + openPredictionResultDialog(); + }; if (isPending || isError) return ; return ( <> -
+ {activePrediction && ( + + )} + {activeTaskId && ( + + )} +
diff --git a/frontend/src/features/user-profile/components/predictions-results-drawer.tsx b/frontend/src/features/user-profile/components/predictions-results-drawer.tsx new file mode 100644 index 000000000..afda92c2f --- /dev/null +++ b/frontend/src/features/user-profile/components/predictions-results-drawer.tsx @@ -0,0 +1,109 @@ +import { DialogProps } from "@/types"; +import { TrainingAreaMap } from "@/features/models/components"; +import { useQuery } from "@tanstack/react-query"; +import { errorMessages, MODELS_CONTENT } from "@/constants"; +import { Spinner } from "@/components/ui/spinner"; +import { Button, ButtonWithIcon } from "@/components/ui/button"; +import { ButtonVariant, DrawerPlacements, SHOELACE_SIZES } from "@/enums"; +import { API_ENDPOINTS, apiClient } from "@/services"; +import { showErrorToast } from "@/utils"; +import { Drawer } from "@/components/ui/drawer"; +import { CloudDownloadIcon } from "@/components/ui/icons"; +import { BASE_API_URL } from "@/config"; + +type PredictionResultProps = DialogProps & { + predictionId: number; + tileServiceUrl: string; +}; + +type TAPIResponse = { + result: string; +}; + +const getPredicitionResultPMTilesUrl = async ( + predictionId: number, +): Promise => { + const { data } = await apiClient.get( + API_ENDPOINTS.GET_PREDICTIONS_PMTILES_URL(predictionId), + ); + if (!data || !data.result) { + showErrorToast(undefined, errorMessages.MAP_LOAD_FAILURE); + throw new Error(errorMessages.MAP_LOAD_FAILURE); + } + return data; +}; + +export const PredictionResultDrawer: React.FC = ({ + isOpened, + closeDialog, + predictionId, + tileServiceUrl, +}) => { + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ["prediction-result-pmtiles-url", predictionId], + queryFn: () => getPredicitionResultPMTilesUrl(predictionId), + enabled: isOpened, + }); + + return ( + +
+ {isLoading && ( +
+ + + {MODELS_CONTENT.trainingArea.map.loadingText} + +
+ )} + + {isError && ( +
+

{errorMessages.MAP_LOAD_FAILURE}

+ +
+ )} + {data?.result && tileServiceUrl && ( +
+
+ { + const downloadUrl = + BASE_API_URL + + API_ENDPOINTS.DOWNLOAD_PREDICTION_LABELS_FILE(predictionId); + // It's possible that the download file is large, so we open it in a new tab + // to avoid blocking the UI. + // This will allow the user to download the file without interrupting their workflow. + window.open(downloadUrl, "_blank"); + }} + label="Download Results" + disabled={!data?.result} + className="!w-fit" + size={SHOELACE_SIZES.MEDIUM} + variant={ButtonVariant.PRIMARY} + prefixIcon={CloudDownloadIcon} + /> +
+
+ +
+
+ )} +
+
+ ); +}; diff --git a/frontend/src/features/user-profile/components/training-logs-dialog.tsx b/frontend/src/features/user-profile/components/training-logs-dialog.tsx new file mode 100644 index 000000000..2d1130953 --- /dev/null +++ b/frontend/src/features/user-profile/components/training-logs-dialog.tsx @@ -0,0 +1,25 @@ +import { Dialog } from "@/components/ui/dialog"; +import { DialogProps } from "@/types"; +import { TrainingLogs } from "@/components/shared/training-logs"; + +type TrainingLogsDialogProps = DialogProps & { + taskId: string; +}; + +export const TrainingLogsDialog: React.FC = ({ + isOpened, + closeDialog, + taskId, +}) => { + return ( + <> + + + + + ); +}; diff --git a/frontend/src/services/api-routes.ts b/frontend/src/services/api-routes.ts index cac03f0bf..47917cd5f 100644 --- a/frontend/src/services/api-routes.ts +++ b/frontend/src/services/api-routes.ts @@ -26,7 +26,6 @@ export const API_ENDPOINTS = { CREATE_OFFLINE_PREDICTION: "prediction/", GET_OFFLINE_PREDICTIONS: "prediction/", - GET_PREDICTIONS_TASK_STATUS: (taskId: string) => `task/status/${taskId}`, // Feedbacks CREATE_FEEDBACK: "feedback/", @@ -82,7 +81,12 @@ export const API_ENDPOINTS = { UPDATE_TRAINING_DATASET: (id: number) => `dataset/${id}/`, GET_TRAINING_DATASETS_CENTROIDS: "datasets/centroid/", - // Workspace + // Workspaces + GET_PREDICTIONS_PMTILES_URL: (predictionID: number) => + `/workspace/download/prediction_${predictionID}/meta.pmtiles/?url_only=true`, + + DOWNLOAD_PREDICTION_LABELS_FILE: (predictionID: number) => + `workspace/download/prediction_${predictionID}/labels.geojson`, GET_PMTILES_URL: (trainingAreaId: number) => `/workspace/download/training_${trainingAreaId}/meta.pmtiles/?url_only=true`, diff --git a/frontend/src/utils/__tests__/geo/geometry-utils.test.ts b/frontend/src/utils/__tests__/geo/geometry-utils.test.ts index 4e18bdf59..417c2a9d2 100644 --- a/frontend/src/utils/__tests__/geo/geometry-utils.test.ts +++ b/frontend/src/utils/__tests__/geo/geometry-utils.test.ts @@ -52,7 +52,7 @@ describe("geometry-utils", () => { it("should format area into human readable string", () => { const result = formatAreaInAppropriateUnit(12222000); - expect(result).toBe("12.2km²"); + expect(result).toBe("12.22km²"); }); it("should compute the bounding box of a GeoJSON Feature", () => { From 6d3a96c9935fe8e0b2e654e12016ee56db6fb2b5 Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Sun, 29 Jun 2025 12:12:36 +0200 Subject: [PATCH 06/19] feat: finalized offline predictions --- .../src/app/routes/models/models-list.tsx | 3 +- .../routes/profile/offline-predictions.tsx | 100 +++++- .../shared}/layout-toggle.tsx | 10 +- .../src/components/shared/model-explorer.tsx | 2 +- .../shared/training-status-badge.tsx | 19 ++ .../src/features/models/components/index.ts | 1 - .../components/offline-predictions-table.tsx | 318 ------------------ .../offline-prediction-card.tsx | 76 +++++ .../offline-prediction-list-skeleton.tsx | 12 + .../offline-predictions-actions.tsx | 151 +++++++++ .../offline-predictions-list.tsx | 71 ++++ .../offline-predictions-settings-info.tsx | 69 ++++ .../offline-predictions-table.tsx | 114 +++++++ 13 files changed, 607 insertions(+), 339 deletions(-) rename frontend/src/{features/models/components => components/shared}/layout-toggle.tsx (89%) create mode 100644 frontend/src/components/shared/training-status-badge.tsx delete mode 100644 frontend/src/features/user-profile/components/offline-predictions-table.tsx create mode 100644 frontend/src/features/user-profile/components/offline-predictions/offline-prediction-card.tsx create mode 100644 frontend/src/features/user-profile/components/offline-predictions/offline-prediction-list-skeleton.tsx create mode 100644 frontend/src/features/user-profile/components/offline-predictions/offline-predictions-actions.tsx create mode 100644 frontend/src/features/user-profile/components/offline-predictions/offline-predictions-list.tsx create mode 100644 frontend/src/features/user-profile/components/offline-predictions/offline-predictions-settings-info.tsx create mode 100644 frontend/src/features/user-profile/components/offline-predictions/offline-predictions-table.tsx diff --git a/frontend/src/app/routes/models/models-list.tsx b/frontend/src/app/routes/models/models-list.tsx index 6476d0849..5a06a2696 100644 --- a/frontend/src/app/routes/models/models-list.tsx +++ b/frontend/src/app/routes/models/models-list.tsx @@ -21,7 +21,8 @@ import { ModelListGridLayout, ModelListTableLayout, } from "@/features/models/layouts"; -import { LayoutToggle, ModelsMap } from "@/features/models/components"; +import { LayoutToggle } from "@/components/shared/layout-toggle"; +import { ModelsMap } from "@/features/models/components"; import { CategoryFilter, DateRangeFilter, diff --git a/frontend/src/app/routes/profile/offline-predictions.tsx b/frontend/src/app/routes/profile/offline-predictions.tsx index c8ffd1c3c..f8518ca36 100644 --- a/frontend/src/app/routes/profile/offline-predictions.tsx +++ b/frontend/src/app/routes/profile/offline-predictions.tsx @@ -1,17 +1,64 @@ import { useAuth } from "@/app/providers/auth-provider"; import { Head } from "@/components/seo"; import { OrderingFilter, Pagination, SearchFilter } from "@/components/shared"; +import { LayoutToggle } from "@/components/shared/layout-toggle"; +import { LayoutView } from "@/enums"; import { ProfileSectionHeader } from "@/features/user-profile/components"; -import OfflinePredictionsTable from "@/features/user-profile/components/offline-predictions-table"; +import OfflinePredictionsTable from "@/features/user-profile/components/offline-predictions/offline-predictions-table"; +import { OfflinePredictionsList } from "@/features/user-profile/components/offline-predictions/offline-predictions-list"; import { useOfflinePredictionsQueryParams } from "@/features/user-profile/hooks/use-predictions"; +import { SEARCH_PARAMS } from "@/utils/search-params"; +import { useDialog } from "@/hooks/use-dialog"; +import { useState } from "react"; +import { TOfflinePrediction } from "@/types"; +import { PredictionResultDrawer } from "@/features/user-profile/components/predictions-results-drawer"; +import { TrainingLogsDialog } from "@/features/user-profile/components/training-logs-dialog"; export const UserProfileOfflinePredictionsPage = () => { const { user } = useAuth(); - const { data, isError, isPending, isPlaceholderData, query, updateQuery } = - useOfflinePredictionsQueryParams(user.osm_id); - + const { + data, + isError, + isPending, + isPlaceholderData, + query, + updateQuery, + refetch, + } = useOfflinePredictionsQueryParams(user.osm_id); + const { isOpened, openDialog, closeDialog } = useDialog(); + const { + isOpened: isPredictionResultOpened, + openDialog: openPredictionResultDialog, + closeDialog: closePredictionResultDialog, + } = useDialog(); + const [activeTaskId, setActiveTaskId] = useState(null); + const [activePrediction, setActivePrediction] = + useState(null); + const handleTrainingLogsModal = (taskId: string) => { + setActiveTaskId(taskId); + openDialog(); + }; + const handlePredictionResultModal = (prediction: TOfflinePrediction) => { + setActivePrediction(prediction); + openPredictionResultDialog(); + }; return ( <> + {activePrediction && ( + + )} + {activeTaskId && ( + + )}
{/* Section heading */} @@ -24,10 +71,19 @@ export const UserProfileOfflinePredictionsPage = () => { className="w-full max-w-full sm:w-auto" />
-
-

- {data?.count} prediction{data?.count && data?.count > 1 ? "s" : ""} -

+
+
+

+ {data?.count} prediction + {data?.count && data?.count > 1 ? "s" : ""} +

+ +
{ scrollToTopOnPageSwitch />
+
- + {query[SEARCH_PARAMS.layout] === LayoutView.GRID ? ( + + ) : ( + + )}
); diff --git a/frontend/src/features/models/components/layout-toggle.tsx b/frontend/src/components/shared/layout-toggle.tsx similarity index 89% rename from frontend/src/features/models/components/layout-toggle.tsx rename to frontend/src/components/shared/layout-toggle.tsx index 56eecf206..65b9dfe3d 100644 --- a/frontend/src/features/models/components/layout-toggle.tsx +++ b/frontend/src/components/shared/layout-toggle.tsx @@ -5,16 +5,18 @@ import { TQueryParams } from "@/types"; import { useScrollToTop } from "@/hooks/use-scroll-to-element"; import { ToolTip } from "@/components/ui/tooltip"; -const LayoutToggle = ({ +export const LayoutToggle = ({ query, updateQuery, isMobile, disabled = false, + iconSize = "icon-lg", }: { updateQuery: (params: TQueryParams) => void; query: TQueryParams; isMobile?: boolean; disabled?: boolean; + iconSize?: string; }) => { const activeLayout = query[SEARCH_PARAMS.layout]; const { scrollToTop } = useScrollToTop(); @@ -36,13 +38,11 @@ const LayoutToggle = ({ disabled={disabled} > {activeLayout !== LayoutView.LIST ? ( - + ) : ( - + )} ); }; - -export default LayoutToggle; diff --git a/frontend/src/components/shared/model-explorer.tsx b/frontend/src/components/shared/model-explorer.tsx index e41e1cb9f..7ace75037 100644 --- a/frontend/src/components/shared/model-explorer.tsx +++ b/frontend/src/components/shared/model-explorer.tsx @@ -1,5 +1,5 @@ import ModelNotFound from "@/features/models/components/model-not-found"; -import { LayoutToggle } from "@/features/models/components"; +import { LayoutToggle } from "@/components/shared/layout-toggle"; import { LayoutView } from "@/enums"; import { MobileModelFiltersDialog } from "@/features/models/components/dialogs"; import { MODELS_CONTENT } from "@/constants"; diff --git a/frontend/src/components/shared/training-status-badge.tsx b/frontend/src/components/shared/training-status-badge.tsx new file mode 100644 index 000000000..24500bd23 --- /dev/null +++ b/frontend/src/components/shared/training-status-badge.tsx @@ -0,0 +1,19 @@ +import { TBadgeVariants } from "@/types"; +import { Badge } from "@/components/ui/badge"; + +export const TrainingStatusBadge = ({ status }: { status: string }) => { + const statusToVariant: Record = { + finished: "green", + failed: "red", + submitted: "blue", + running: "yellow", + }; + + return ( + + {status.toLocaleLowerCase() as string} + + ); +}; diff --git a/frontend/src/features/models/components/index.ts b/frontend/src/features/models/components/index.ts index a16656338..c5b654b83 100644 --- a/frontend/src/features/models/components/index.ts +++ b/frontend/src/features/models/components/index.ts @@ -8,5 +8,4 @@ export { default as ModelDetailsInfo } from "./model-details-info"; export { default as ModelDetailItem } from "./model-detail-item"; export { default as ModelDetailsProperties } from "./model-details-properties"; export { default as TrainingHistoryTable } from "./training-history-table"; -export { default as LayoutToggle } from "./layout-toggle"; export { TrainingAreaButton } from "./training-area-button"; diff --git a/frontend/src/features/user-profile/components/offline-predictions-table.tsx b/frontend/src/features/user-profile/components/offline-predictions-table.tsx deleted file mode 100644 index 203b7ef50..000000000 --- a/frontend/src/features/user-profile/components/offline-predictions-table.tsx +++ /dev/null @@ -1,318 +0,0 @@ -import { Badge } from "@/components/ui/badge"; -import { ColumnDef, SortingState } from "@tanstack/react-table"; -import { DataTable } from "@/components/ui/data-table"; - -import { SortableHeader } from "@/features/models/components/table-header"; -import { TableSkeleton } from "@/features/models/components/skeletons"; -import { TBadgeVariants, TOfflinePrediction } from "@/types"; -import { useState } from "react"; -import { - formatDate, - formatDuration, - showSuccessToast, - showWarningToast, - truncateString, -} from "@/utils"; -import { DropDown } from "@/components/ui/dropdown"; -import { ElipsisIcon, InfoIcon } from "@/components/ui/icons"; -import { DropdownPlacement, ModelTrainingStatus } from "@/enums"; -import useCopyToClipboard from "@/hooks/use-clipboard"; -import { API_ENDPOINTS } from "@/services"; -import { BASE_API_URL } from "@/config"; -import { TrainingLogsDialog } from "./training-logs-dialog"; -import { useDialog } from "@/hooks/use-dialog"; -import { PredictionResultDrawer } from "./predictions-results-drawer"; - -type OfflinePredictionsTableProps = { - data: TOfflinePrediction[]; - isError: boolean; - isPending: boolean; -}; - -const columnDefinitions = ( - handleTrainingLogsModal: (taskId: string) => void, - handlePredictionResultModal: (prediction: TOfflinePrediction) => void, -): ColumnDef[] => [ - { - accessorKey: "id", - header: ({ column }) => , - }, - { - header: "Prediction Name", - accessorFn: (row) => - row.description && row.description.length > 0 - ? truncateString(row.description) - : "-", - }, - - { - accessorKey: "created_at", - accessorFn: (row) => - row.created_at !== null ? formatDate(row.created_at) : "-", - header: "Date Submitted", - cell: (row) => { - return {row.getValue() as string}; - }, - }, - { - accessorFn: (row) => row.config.zoom_level, - header: "Zoom Level", - cell: (row) => { - return {row.getValue() as string}; - }, - }, - { - header: "Status", - accessorKey: "status", - cell: (row) => { - const statusToVariant: Record = { - finished: "green", - failed: "red", - submitted: "blue", - running: "yellow", - }; - - return ( - - {String(row.getValue()).toLocaleLowerCase() as string} - - ); - }, - }, - { - header: "Duration", - accessorFn: (row) => - row.finished_at && row.started_at - ? formatDuration(new Date(row.started_at), new Date(row.finished_at)) - : "-", - cell: (row) => ( - {row.getValue() as string} - ), - }, - { - header: "Info", - cell: ({ row }: { row: any }) => { - return ( - { - e.stopPropagation(); - }} - className="rounded-lg px-2 items-center flex" - > - - - } - className="text-right" - distance={10} - > -
-

- Settings Info -

- {Object.entries(row.original.config) - .filter( - ([key]) => - key !== "checkpoint" && key !== "source" && key !== "bbox", - ) - .map(([key, value]) => ( - - {key - .replace(/_/g, " ") - .replace(/\b\w/g, (l) => l.toUpperCase())} - :{" "} - - {typeof value === "boolean" - ? value - ? "True" - : "False" - : String(value)} - - - ))} -
-
- ); - }, - }, - { - header: "Actions", - cell: ({ row }: { row: any }) => { - const { copyToClipboard } = useCopyToClipboard(); - return ( - { - // Prevent the row click event from firing - e.stopPropagation(); - }} - className="rounded-lg px-2 items-center flex" - > - - - } - className="text-right" - distance={10} - menuItems={[ - { - name: "Download results", - value: "Download results", - onClick: (e) => { - e.stopPropagation(); - const downloadUrl = - BASE_API_URL + - API_ENDPOINTS.DOWNLOAD_PREDICTION_LABELS_FILE( - row.original.id, - ); - // It's possible that the download file is large, so we open it in a new tab - // to avoid blocking the UI. - // This will allow the user to download the file without interrupting their workflow. - window.open(downloadUrl, "_blank"); - }, - disabled: row.getValue("status") !== ModelTrainingStatus.FINISHED, - }, - { - name: "View results", - value: "View results", - onClick: (e) => { - e.stopPropagation(); - handlePredictionResultModal(row.original); - }, - disabled: row.getValue("status") !== ModelTrainingStatus.FINISHED, - }, - { - name: "Copy results link", - value: "Copy results link", - onClick: async (e) => { - e.stopPropagation(); - await copyToClipboard( - BASE_API_URL + - API_ENDPOINTS.DOWNLOAD_PREDICTION_LABELS_FILE( - row.original.id, - ), - ); - showSuccessToast("Copied results link to clipboard!"); - }, - disabled: row.getValue("status") !== ModelTrainingStatus.FINISHED, - }, - { - name: !row.original.mapswipe_id - ? "Create MapSwipe project" - : "View MapSwipe project", - value: !row.original.mapswipe_id - ? "Create MapSwipe project" - : "View MapSwipe project", - onClick: (e) => { - // can be used to create or view a MapSwipe project - // Prevent the row click event from firing - e.stopPropagation(); - showWarningToast( - "This feature is not yet implemented. Please check back later.", - ); - }, - disabled: row.getValue("status") !== ModelTrainingStatus.FINISHED, - }, - { - name: "View logs", - value: "View logs", - disabled: ![ - ModelTrainingStatus.FAILED, - ModelTrainingStatus.IN_PROGRESS, - ].includes(row.getValue("status")), - onClick: (e) => { - // Prevent the row click event from firing - e.stopPropagation(); - handleTrainingLogsModal(row.original.task_id as string); - }, - }, - { - name: "Copy imagery link", - value: "Copy imagery link", - onClick: async (e) => { - e.stopPropagation(); - await copyToClipboard(row.original.config.source); - showSuccessToast("Copied imagery link to clipboard"); - }, - }, - ]} - /> - ); - }, - }, -]; - -const OfflinePredictionsTable: React.FC = ({ - data, - isPending, - isError, -}) => { - const [sorting, setSorting] = useState([]); - const { isOpened, openDialog, closeDialog } = useDialog(); - const { - isOpened: isPredictionResultOpened, - openDialog: openPredictionResultDialog, - closeDialog: closePredictionResultDialog, - } = useDialog(); - const [activeTaskId, setActiveTaskId] = useState(null); - const [activePrediction, setActivePrediction] = - useState(null); - const handleTrainingLogsModal = (taskId: string) => { - setActiveTaskId(taskId); - openDialog(); - }; - const handlePredictionResultModal = (prediction: TOfflinePrediction) => { - setActivePrediction(prediction); - openPredictionResultDialog(); - }; - if (isPending || isError) return ; - - return ( - <> - {activePrediction && ( - - )} - {activeTaskId && ( - - )} -
- -
- - ); -}; - -export default OfflinePredictionsTable; diff --git a/frontend/src/features/user-profile/components/offline-predictions/offline-prediction-card.tsx b/frontend/src/features/user-profile/components/offline-predictions/offline-prediction-card.tsx new file mode 100644 index 000000000..b05e4079e --- /dev/null +++ b/frontend/src/features/user-profile/components/offline-predictions/offline-prediction-card.tsx @@ -0,0 +1,76 @@ +import { TrainingStatusBadge } from "@/components/shared/training-status-badge"; +import { Button } from "@/components/ui/button"; +import { ButtonVariant, DropdownPlacement, SHOELACE_SIZES } from "@/enums"; +import { TOfflinePrediction } from "@/types"; +import { formatDate, formatDuration } from "@/utils"; +import { OfflinePredictionActions } from "./offline-predictions-actions"; + +export const OfflinePredictionCard = ({ + predictionResult, + handleTrainingLogsModal, + handlePredictionResultModal, +}: { + predictionResult: TOfflinePrediction; + handleTrainingLogsModal: (taskId: string) => void; + handlePredictionResultModal: (prediction: TOfflinePrediction) => void; +}) => { + return ( +
+
+
+

+ {!predictionResult.description + ? `Untitled prediction ${predictionResult.id}` + : predictionResult.description} +

+ +
+ +
+ + + +
+

+ Date Submitted:{" "} + + {predictionResult.created_at + ? formatDate(predictionResult.created_at) + : "-"} + +

+
+
+ ); +}; diff --git a/frontend/src/features/user-profile/components/offline-predictions/offline-prediction-list-skeleton.tsx b/frontend/src/features/user-profile/components/offline-predictions/offline-prediction-list-skeleton.tsx new file mode 100644 index 000000000..cf62e1f60 --- /dev/null +++ b/frontend/src/features/user-profile/components/offline-predictions/offline-prediction-list-skeleton.tsx @@ -0,0 +1,12 @@ +export const OfflinePredictionsListSkeleton = () => { + return ( +
+ {Array.from({ length: 12 }).map((_, index) => ( +
+ ))} +
+ ); +}; diff --git a/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-actions.tsx b/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-actions.tsx new file mode 100644 index 000000000..95a12ca99 --- /dev/null +++ b/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-actions.tsx @@ -0,0 +1,151 @@ +import { Badge } from "@/components/ui/badge"; +import { DropDown } from "@/components/ui/dropdown"; +import { ElipsisIcon } from "@/components/ui/icons"; +import { BASE_API_URL } from "@/config"; +import { DropdownPlacement, ModelTrainingStatus } from "@/enums"; +import useCopyToClipboard from "@/hooks/use-clipboard"; +import { API_ENDPOINTS } from "@/services"; +import { TOfflinePrediction } from "@/types"; +import { showSuccessToast, showWarningToast } from "@/utils"; +import { OfflinePredictionsSettingsInfo } from "./offline-predictions-settings-info"; +import { useDropdownMenu } from "@/hooks/use-dropdown-menu"; + +export const OfflinePredictionActions = ({ + handlePredictionResultModal, + handleTrainingLogsModal, + predictionResult, + showSettingsInfo = false, + placement, +}: { + handlePredictionResultModal: (prediction: any) => void; + handleTrainingLogsModal: (taskId: string) => void; + predictionResult: TOfflinePrediction; + showSettingsInfo?: boolean; + placement?: DropdownPlacement; +}) => { + const { copyToClipboard } = useCopyToClipboard(); + const { dropdownRef } = useDropdownMenu(); + + const handleSettingsInfo = () => { + if (dropdownRef?.current) { + dropdownRef.current.show(); + } + }; + + return ( + <> + + + { + // Prevent the row click event from firing + e.stopPropagation(); + }} + className="rounded-lg px-2 items-center flex" + > + + + } + className="text-right" + distance={10} + menuItems={[ + { + name: "Download results", + value: "Download results", + onClick: (e) => { + e.stopPropagation(); + const downloadUrl = + BASE_API_URL + + API_ENDPOINTS.DOWNLOAD_PREDICTION_LABELS_FILE( + predictionResult.id, + ); + window.open(downloadUrl, "_blank"); + }, + disabled: predictionResult.status !== ModelTrainingStatus.FINISHED, + }, + { + name: "View results", + value: "View results", + onClick: (e) => { + e.stopPropagation(); + handlePredictionResultModal(predictionResult); + }, + disabled: predictionResult.status !== ModelTrainingStatus.FINISHED, + }, + { + name: "Copy results link", + value: "Copy results link", + onClick: async (e) => { + e.stopPropagation(); + await copyToClipboard( + BASE_API_URL + + API_ENDPOINTS.DOWNLOAD_PREDICTION_LABELS_FILE( + predictionResult.id, + ), + ); + showSuccessToast("Copied results link to clipboard!"); + }, + disabled: predictionResult.status !== ModelTrainingStatus.FINISHED, + }, + ...(showSettingsInfo + ? [ + { + name: "View settings info", + value: "View settings info", + onClick: (e: { stopPropagation: () => void }) => { + e.stopPropagation(); + handleSettingsInfo(); + }, + }, + ] + : []), + { + name: !predictionResult.mapswipe_id + ? "Create MapSwipe project" + : "View MapSwipe project", + value: !predictionResult.mapswipe_id + ? "Create MapSwipe project" + : "View MapSwipe project", + onClick: (e) => { + e.stopPropagation(); + showWarningToast( + "This feature is not yet implemented. Please check back later.", + ); + }, + disabled: predictionResult.status !== ModelTrainingStatus.FINISHED, + }, + { + name: "View logs", + value: "View logs", + disabled: ![ + ModelTrainingStatus.FAILED, + ModelTrainingStatus.IN_PROGRESS, + ].includes(predictionResult.status), + onClick: (e) => { + e.stopPropagation(); + handleTrainingLogsModal(predictionResult.task_id as string); + }, + }, + { + name: "Copy imagery link", + value: "Copy imagery link", + onClick: async (e) => { + e.stopPropagation(); + await copyToClipboard(predictionResult.config.source); + showSuccessToast("Copied imagery link to clipboard"); + }, + }, + ]} + /> + + ); +}; diff --git a/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-list.tsx b/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-list.tsx new file mode 100644 index 000000000..ae87fd00e --- /dev/null +++ b/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-list.tsx @@ -0,0 +1,71 @@ +import { Button } from "@/components/ui/button"; +import { NoTrainingAreaIcon } from "@/components/ui/icons"; +import { TOfflinePrediction } from "@/types"; +import { OfflinePredictionsListSkeleton } from "./offline-prediction-list-skeleton"; +import { OfflinePredictionCard } from "./offline-prediction-card"; + +export const OfflinePredictionsList = ({ + data, + isPending, + isError, + refetch, + handleTrainingLogsModal, + handlePredictionResultModal, +}: { + data: TOfflinePrediction[]; + isPending: boolean; + isError: boolean; + refetch: () => void; + handleTrainingLogsModal: (taskId: string) => void; + handlePredictionResultModal: (prediction: TOfflinePrediction) => void; +}) => { + /** + * Pending state. + */ + if (isPending) { + return ; + } + + /** + * Error state. + */ + if (isError) { + return ( +
+ Error loading offline predictions. + +
+ ); + } + + /** + * Empty state. + */ + + if (data.length === 0) { + return ( +
+ +

No offline predictions found.

+
+ ); + } + + /** + * Dataset list + */ + return ( +
+ {data.map((data) => ( + + ))} +
+ ); +}; diff --git a/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-settings-info.tsx b/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-settings-info.tsx new file mode 100644 index 000000000..9c76e14b9 --- /dev/null +++ b/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-settings-info.tsx @@ -0,0 +1,69 @@ +import { Badge } from "@/components/ui/badge"; +import { DropDown } from "@/components/ui/dropdown"; +import { InfoIcon } from "@/components/ui/icons"; +import { DropdownPlacement } from "@/enums"; +import { TPredictionsConfig } from "@/types"; +import { SlDropdown } from "@shoelace-style/shoelace"; +import { MutableRefObject } from "react"; + +export const OfflinePredictionsSettingsInfo = ({ + predictionConfig, + dropdownRef, + disableSettingsInfoIcon, + placement = DropdownPlacement.BOTTOM_END, +}: { + predictionConfig: TPredictionsConfig; + dropdownRef?: MutableRefObject; + disableSettingsInfoIcon?: boolean; + placement?: DropdownPlacement; +}) => { + return ( + { + e.stopPropagation(); + }} + className="rounded-lg px-2 items-center flex" + > + + + ) : null + } + className="text-right" + distance={10} + > +
+

+ Settings Info +

+ {Object.entries(predictionConfig) + .filter( + ([key]) => + key !== "checkpoint" && key !== "source" && key !== "bbox", + ) + .map(([key, value]) => ( + + {key.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase())}:{" "} + + {typeof value === "boolean" + ? value + ? "True" + : "False" + : String(value)} + + + ))} +
+
+ ); +}; diff --git a/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-table.tsx b/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-table.tsx new file mode 100644 index 000000000..930fd0b9a --- /dev/null +++ b/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-table.tsx @@ -0,0 +1,114 @@ +import { ColumnDef, SortingState } from "@tanstack/react-table"; +import { DataTable } from "@/components/ui/data-table"; +import { SortableHeader } from "@/features/models/components/table-header"; +import { TableSkeleton } from "@/features/models/components/skeletons"; +import { TOfflinePrediction } from "@/types"; +import { useState } from "react"; +import { formatDate, formatDuration, truncateString } from "@/utils"; + +import { TrainingStatusBadge } from "@/components/shared/training-status-badge"; +import { OfflinePredictionsSettingsInfo } from "./offline-predictions-settings-info"; +import { OfflinePredictionActions } from "./offline-predictions-actions"; + +type OfflinePredictionsTableProps = { + data: TOfflinePrediction[]; + isError: boolean; + isPending: boolean; + handleTrainingLogsModal: (taskId: string) => void; + handlePredictionResultModal: (prediction: TOfflinePrediction) => void; +}; + +const columnDefinitions = ( + handleTrainingLogsModal: (taskId: string) => void, + handlePredictionResultModal: (prediction: TOfflinePrediction) => void, +): ColumnDef[] => [ + { + accessorKey: "id", + header: ({ column }) => , + }, + { + header: "Prediction Name", + accessorFn: (row) => + row.description && row.description.length > 0 + ? truncateString(row.description) + : "-", + }, + + { + accessorKey: "created_at", + accessorFn: (row) => + row.created_at !== null ? formatDate(row.created_at) : "-", + header: "Date Submitted", + cell: (row) => { + return {row.getValue() as string}; + }, + }, + { + accessorFn: (row) => row.config.zoom_level, + header: "Zoom Level", + cell: (row) => { + return {row.getValue() as string}; + }, + }, + { + header: "Status", + accessorKey: "status", + cell: (row) => , + }, + { + header: "Duration", + accessorFn: (row) => + row.finished_at && row.started_at + ? formatDuration(new Date(row.started_at), new Date(row.finished_at)) + : "-", + cell: (row) => ( + {row.getValue() as string} + ), + }, + { + header: "Info", + cell: ({ row }: { row: any }) => ( + + ), + }, + { + header: "Actions", + cell: ({ row }: { row: any }) => ( + + ), + }, + ]; + +const OfflinePredictionsTable: React.FC = ({ + data, + isPending, + isError, + handleTrainingLogsModal, + handlePredictionResultModal, +}) => { + const [sorting, setSorting] = useState([]); + + if (isPending || isError) return ; + + return ( +
+ +
+ ); +}; + +export default OfflinePredictionsTable; From 6a0d98df885ada6d908c8b0a3ea95828be4a0091 Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Sun, 29 Jun 2025 12:41:23 +0200 Subject: [PATCH 07/19] feat: finalized the mobile responsiveness of offline predictions --- frontend/src/app/routes/start-mapping.tsx | 58 +++- .../offline-prediction-request-dialog.tsx | 6 +- .../start-mapping/components/map/map.tsx | 8 +- .../components/mobile-drawer.tsx | 11 + .../offline-prediction-card.tsx | 6 +- .../offline-predictions-actions.tsx | 264 +++++++++--------- .../offline-predictions-settings-info.tsx | 114 ++++---- .../offline-predictions-table.tsx | 115 ++++---- 8 files changed, 325 insertions(+), 257 deletions(-) diff --git a/frontend/src/app/routes/start-mapping.tsx b/frontend/src/app/routes/start-mapping.tsx index 81d90e3ba..a35b1f320 100644 --- a/frontend/src/app/routes/start-mapping.tsx +++ b/frontend/src/app/routes/start-mapping.tsx @@ -4,7 +4,12 @@ import { START_MAPPING_PAGE_CONTENT, TOAST_NOTIFICATIONS, } from "@/constants"; -import { FitToBounds, LayerControl, ZoomLevel } from "@/components/map"; +import { + DrawControl, + FitToBounds, + LayerControl, + ZoomLevel, +} from "@/components/map"; import { Head } from "@/components/seo"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useMapInstance } from "@/hooks/use-map-instance"; @@ -41,7 +46,12 @@ import { ImagerySourceSelector } from "@/features/start-mapping/components/repli import { useDialog } from "@/hooks/use-dialog"; import { useModelPredictionStore } from "@/store/model-prediction-store"; import { ModelSelector } from "@/features/start-mapping/components/replicable-models/model-selector"; -import { BASE_MODELS, DrawingModes, TileServiceType } from "@/enums"; +import { + BASE_MODELS, + DrawingModes, + TileServiceType, + ToolTipPlacement, +} from "@/enums"; import { useTileservice } from "@/hooks/use-tileservice"; import { ALL_MODEL_PREDICTIONS_FILL_LAYER_ID, @@ -51,6 +61,8 @@ import { } from "@/config"; import { OfflinePredictionRequestDialog } from "@/features/start-mapping/components/dialogs/offline-prediction-request-dialog"; import { GeoJSONStoreFeatures } from "terra-draw"; +import { FileUploadIcon } from "@/components/ui/icons"; +import { ToolTip } from "@/components/ui/tooltip"; export type TDownloadOptions = { name: string; @@ -763,6 +775,11 @@ export const StartMappingPage = () => { modelPredictions={modelPredictions} setModelPredictions={setModelPredictions} isSmallViewport={isSmallViewport} + isOfflineMode={isOfflineMode} + hasDrawnAOI={hasDrawnAOI} + openOfflinePredictionRequestDialog={ + openOfflinePredictionRequestDialog + } /> )} {/* Mobile bottom sheet */} @@ -817,6 +834,43 @@ export const StartMappingPage = () => {
+
+ {terraDraw && map && ( + + )} +
+ {terraDraw && map && ( +
+ + + +
+ )}
- + Set the parameters for your prediction request. Selected model and imagery will be used to generate predictions for the selected zoom level. You can also set advanced settings for your prediction @@ -110,7 +110,7 @@ export const OfflinePredictionRequestDialog = ({ { label: "Zoom 20", value: "20" }, { label: "Zoom 21", value: "21" }, ]} - className="!flex-row items-center gap-x-8" + className="md:items-center gap-x-8 flex-col md:flex-row" value={zoomLevel} onChange={(selection) => setZoomLevel(selection)} /> @@ -127,7 +127,7 @@ export const OfflinePredictionRequestDialog = ({
-
+
diff --git a/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-actions.tsx b/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-actions.tsx index 95a12ca99..e5a628203 100644 --- a/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-actions.tsx +++ b/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-actions.tsx @@ -11,141 +11,141 @@ import { OfflinePredictionsSettingsInfo } from "./offline-predictions-settings-i import { useDropdownMenu } from "@/hooks/use-dropdown-menu"; export const OfflinePredictionActions = ({ - handlePredictionResultModal, - handleTrainingLogsModal, - predictionResult, - showSettingsInfo = false, - placement, + handlePredictionResultModal, + handleTrainingLogsModal, + predictionResult, + showSettingsInfo = false, + placement, }: { - handlePredictionResultModal: (prediction: any) => void; - handleTrainingLogsModal: (taskId: string) => void; - predictionResult: TOfflinePrediction; - showSettingsInfo?: boolean; - placement?: DropdownPlacement; + handlePredictionResultModal: (prediction: any) => void; + handleTrainingLogsModal: (taskId: string) => void; + predictionResult: TOfflinePrediction; + showSettingsInfo?: boolean; + placement?: DropdownPlacement; }) => { - const { copyToClipboard } = useCopyToClipboard(); - const { dropdownRef } = useDropdownMenu(); + const { copyToClipboard } = useCopyToClipboard(); + const { dropdownRef } = useDropdownMenu(); - const handleSettingsInfo = () => { - if (dropdownRef?.current) { - dropdownRef.current.show(); - } - }; + const handleSettingsInfo = () => { + if (dropdownRef?.current) { + dropdownRef.current.show(); + } + }; - return ( - <> - + return ( + <> + - { - // Prevent the row click event from firing - e.stopPropagation(); - }} - className="rounded-lg px-2 items-center flex" - > - - - } - className="text-right" - distance={10} - menuItems={[ - { - name: "Download results", - value: "Download results", - onClick: (e) => { - e.stopPropagation(); - const downloadUrl = - BASE_API_URL + - API_ENDPOINTS.DOWNLOAD_PREDICTION_LABELS_FILE( - predictionResult.id, - ); - window.open(downloadUrl, "_blank"); - }, - disabled: predictionResult.status !== ModelTrainingStatus.FINISHED, - }, - { - name: "View results", - value: "View results", - onClick: (e) => { - e.stopPropagation(); - handlePredictionResultModal(predictionResult); - }, - disabled: predictionResult.status !== ModelTrainingStatus.FINISHED, - }, - { - name: "Copy results link", - value: "Copy results link", - onClick: async (e) => { - e.stopPropagation(); - await copyToClipboard( - BASE_API_URL + - API_ENDPOINTS.DOWNLOAD_PREDICTION_LABELS_FILE( - predictionResult.id, - ), - ); - showSuccessToast("Copied results link to clipboard!"); - }, - disabled: predictionResult.status !== ModelTrainingStatus.FINISHED, - }, - ...(showSettingsInfo - ? [ - { - name: "View settings info", - value: "View settings info", - onClick: (e: { stopPropagation: () => void }) => { - e.stopPropagation(); - handleSettingsInfo(); - }, - }, - ] - : []), - { - name: !predictionResult.mapswipe_id - ? "Create MapSwipe project" - : "View MapSwipe project", - value: !predictionResult.mapswipe_id - ? "Create MapSwipe project" - : "View MapSwipe project", - onClick: (e) => { - e.stopPropagation(); - showWarningToast( - "This feature is not yet implemented. Please check back later.", - ); - }, - disabled: predictionResult.status !== ModelTrainingStatus.FINISHED, - }, - { - name: "View logs", - value: "View logs", - disabled: ![ - ModelTrainingStatus.FAILED, - ModelTrainingStatus.IN_PROGRESS, - ].includes(predictionResult.status), - onClick: (e) => { - e.stopPropagation(); - handleTrainingLogsModal(predictionResult.task_id as string); - }, - }, - { - name: "Copy imagery link", - value: "Copy imagery link", - onClick: async (e) => { - e.stopPropagation(); - await copyToClipboard(predictionResult.config.source); - showSuccessToast("Copied imagery link to clipboard"); - }, - }, - ]} - /> - - ); + { + // Prevent the row click event from firing + e.stopPropagation(); + }} + className="rounded-lg px-2 items-center flex" + > + + + } + className="text-right" + distance={10} + menuItems={[ + { + name: "Download results", + value: "Download results", + onClick: (e) => { + e.stopPropagation(); + const downloadUrl = + BASE_API_URL + + API_ENDPOINTS.DOWNLOAD_PREDICTION_LABELS_FILE( + predictionResult.id, + ); + window.open(downloadUrl, "_blank"); + }, + disabled: predictionResult.status !== ModelTrainingStatus.FINISHED, + }, + { + name: "View results", + value: "View results", + onClick: (e) => { + e.stopPropagation(); + handlePredictionResultModal(predictionResult); + }, + disabled: predictionResult.status !== ModelTrainingStatus.FINISHED, + }, + { + name: "Copy results link", + value: "Copy results link", + onClick: async (e) => { + e.stopPropagation(); + await copyToClipboard( + BASE_API_URL + + API_ENDPOINTS.DOWNLOAD_PREDICTION_LABELS_FILE( + predictionResult.id, + ), + ); + showSuccessToast("Copied results link to clipboard!"); + }, + disabled: predictionResult.status !== ModelTrainingStatus.FINISHED, + }, + ...(showSettingsInfo + ? [ + { + name: "View settings info", + value: "View settings info", + onClick: (e: { stopPropagation: () => void }) => { + e.stopPropagation(); + handleSettingsInfo(); + }, + }, + ] + : []), + { + name: !predictionResult.mapswipe_id + ? "Create MapSwipe project" + : "View MapSwipe project", + value: !predictionResult.mapswipe_id + ? "Create MapSwipe project" + : "View MapSwipe project", + onClick: (e) => { + e.stopPropagation(); + showWarningToast( + "This feature is not yet implemented. Please check back later.", + ); + }, + disabled: predictionResult.status !== ModelTrainingStatus.FINISHED, + }, + { + name: "View logs", + value: "View logs", + disabled: ![ + ModelTrainingStatus.FAILED, + ModelTrainingStatus.IN_PROGRESS, + ].includes(predictionResult.status), + onClick: (e) => { + e.stopPropagation(); + handleTrainingLogsModal(predictionResult.task_id as string); + }, + }, + { + name: "Copy imagery link", + value: "Copy imagery link", + onClick: async (e) => { + e.stopPropagation(); + await copyToClipboard(predictionResult.config.source); + showSuccessToast("Copied imagery link to clipboard"); + }, + }, + ]} + /> + + ); }; diff --git a/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-settings-info.tsx b/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-settings-info.tsx index 9c76e14b9..4728cd8a7 100644 --- a/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-settings-info.tsx +++ b/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-settings-info.tsx @@ -7,63 +7,63 @@ import { SlDropdown } from "@shoelace-style/shoelace"; import { MutableRefObject } from "react"; export const OfflinePredictionsSettingsInfo = ({ - predictionConfig, - dropdownRef, - disableSettingsInfoIcon, - placement = DropdownPlacement.BOTTOM_END, + predictionConfig, + dropdownRef, + disableSettingsInfoIcon, + placement = DropdownPlacement.BOTTOM_END, }: { - predictionConfig: TPredictionsConfig; - dropdownRef?: MutableRefObject; - disableSettingsInfoIcon?: boolean; - placement?: DropdownPlacement; + predictionConfig: TPredictionsConfig; + dropdownRef?: MutableRefObject; + disableSettingsInfoIcon?: boolean; + placement?: DropdownPlacement; }) => { - return ( - { - e.stopPropagation(); - }} - className="rounded-lg px-2 items-center flex" - > - - - ) : null - } - className="text-right" - distance={10} - > -
-

- Settings Info -

- {Object.entries(predictionConfig) - .filter( - ([key]) => - key !== "checkpoint" && key !== "source" && key !== "bbox", - ) - .map(([key, value]) => ( - - {key.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase())}:{" "} - - {typeof value === "boolean" - ? value - ? "True" - : "False" - : String(value)} - - - ))} -
-
- ); + return ( + { + e.stopPropagation(); + }} + className="rounded-lg px-2 items-center flex" + > + + + ) : null + } + className="text-right" + distance={10} + > +
+

+ Settings Info +

+ {Object.entries(predictionConfig) + .filter( + ([key]) => + key !== "checkpoint" && key !== "source" && key !== "bbox", + ) + .map(([key, value]) => ( + + {key.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase())}:{" "} + + {typeof value === "boolean" + ? value + ? "True" + : "False" + : String(value)} + + + ))} +
+
+ ); }; diff --git a/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-table.tsx b/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-table.tsx index 930fd0b9a..c950e63c9 100644 --- a/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-table.tsx +++ b/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-table.tsx @@ -22,67 +22,66 @@ const columnDefinitions = ( handleTrainingLogsModal: (taskId: string) => void, handlePredictionResultModal: (prediction: TOfflinePrediction) => void, ): ColumnDef[] => [ - { - accessorKey: "id", - header: ({ column }) => , - }, - { - header: "Prediction Name", - accessorFn: (row) => - row.description && row.description.length > 0 - ? truncateString(row.description) - : "-", - }, + { + accessorKey: "id", + header: ({ column }) => , + }, + { + header: "Prediction Name", + accessorFn: (row) => + row.description && row.description.length > 0 + ? truncateString(row.description) + : "-", + }, - { - accessorKey: "created_at", - accessorFn: (row) => - row.created_at !== null ? formatDate(row.created_at) : "-", - header: "Date Submitted", - cell: (row) => { - return {row.getValue() as string}; - }, - }, - { - accessorFn: (row) => row.config.zoom_level, - header: "Zoom Level", - cell: (row) => { - return {row.getValue() as string}; - }, - }, - { - header: "Status", - accessorKey: "status", - cell: (row) => , + { + accessorKey: "created_at", + accessorFn: (row) => + row.created_at !== null ? formatDate(row.created_at) : "-", + header: "Date Submitted", + cell: (row) => { + return {row.getValue() as string}; }, - { - header: "Duration", - accessorFn: (row) => - row.finished_at && row.started_at - ? formatDuration(new Date(row.started_at), new Date(row.finished_at)) - : "-", - cell: (row) => ( - {row.getValue() as string} - ), + }, + { + accessorFn: (row) => row.config.zoom_level, + header: "Zoom Level", + cell: (row) => { + return {row.getValue() as string}; }, - { - header: "Info", - cell: ({ row }: { row: any }) => ( - - ), - }, - { - header: "Actions", - cell: ({ row }: { row: any }) => ( - - ), - }, - ]; + }, + { + header: "Status", + accessorKey: "status", + cell: (row) => , + }, + { + header: "Duration", + accessorFn: (row) => + row.finished_at && row.started_at + ? formatDuration(new Date(row.started_at), new Date(row.finished_at)) + : "-", + cell: (row) => ( + {row.getValue() as string} + ), + }, + { + header: "Info", + cell: ({ row }: { row: any }) => ( + + ), + }, + { + header: "Actions", + cell: ({ row }: { row: any }) => ( + + ), + }, +]; const OfflinePredictionsTable: React.FC = ({ data, From 372735ec22c8a80e790582f7018469b4a4511c11 Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Sun, 29 Jun 2025 13:01:04 +0200 Subject: [PATCH 08/19] chore: enable logs in running predictions --- frontend/src/enums/common.ts | 1 + .../offline-predictions/offline-predictions-actions.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/frontend/src/enums/common.ts b/frontend/src/enums/common.ts index 2430bd581..802fd89c2 100644 --- a/frontend/src/enums/common.ts +++ b/frontend/src/enums/common.ts @@ -81,6 +81,7 @@ export enum ModelTrainingStatus { IN_PROGRESS = "IN_PROGRESS", FINISHED = "FINISHED", FAILED = "FAILED", + RUNNING = "RUNNING", } export enum TileServiceType { diff --git a/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-actions.tsx b/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-actions.tsx index e5a628203..2e80075c2 100644 --- a/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-actions.tsx +++ b/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-actions.tsx @@ -129,6 +129,7 @@ export const OfflinePredictionActions = ({ disabled: ![ ModelTrainingStatus.FAILED, ModelTrainingStatus.IN_PROGRESS, + ModelTrainingStatus.RUNNING, ].includes(predictionResult.status), onClick: (e) => { e.stopPropagation(); From 55f7358213a3df303c85016685d8672b8fceb684 Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Sun, 29 Jun 2025 14:49:52 +0200 Subject: [PATCH 09/19] chore: updated prediction card --- .../offline-prediction-card.tsx | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/frontend/src/features/user-profile/components/offline-predictions/offline-prediction-card.tsx b/frontend/src/features/user-profile/components/offline-predictions/offline-prediction-card.tsx index a64a627ad..04538ae20 100644 --- a/frontend/src/features/user-profile/components/offline-predictions/offline-prediction-card.tsx +++ b/frontend/src/features/user-profile/components/offline-predictions/offline-prediction-card.tsx @@ -37,29 +37,22 @@ export const OfflinePredictionCard = ({
-

@@ -70,6 +63,14 @@ export const OfflinePredictionCard = ({ : "-"}

+

+ Duration:{" "} + + {predictionResult.created_at && predictionResult.finished_at + ? formatDuration(new Date(predictionResult.finished_at), new Date(predictionResult.created_at)) + : "-"} + +

); From 95c0e9f4108d3772659ed70673b8f8a0b70c5854 Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Sun, 29 Jun 2025 20:43:51 +0200 Subject: [PATCH 10/19] fix: fixed layout toggle bug --- frontend/src/app/routes/profile/offline-predictions.tsx | 2 +- frontend/src/components/shared/layout-toggle.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/routes/profile/offline-predictions.tsx b/frontend/src/app/routes/profile/offline-predictions.tsx index f8518ca36..6ae3201ce 100644 --- a/frontend/src/app/routes/profile/offline-predictions.tsx +++ b/frontend/src/app/routes/profile/offline-predictions.tsx @@ -111,7 +111,7 @@ export const UserProfileOfflinePredictionsPage = () => { />
- {query[SEARCH_PARAMS.layout] === LayoutView.GRID ? ( + {query[SEARCH_PARAMS.layout] === LayoutView.LIST ? ( { const activeLayout = query[SEARCH_PARAMS.layout]; const { scrollToTop } = useScrollToTop(); + return (
diff --git a/frontend/src/app/routes/start-mapping.tsx b/frontend/src/app/routes/start-mapping.tsx index a35b1f320..96d4c05d0 100644 --- a/frontend/src/app/routes/start-mapping.tsx +++ b/frontend/src/app/routes/start-mapping.tsx @@ -59,7 +59,7 @@ import { FAIR_BASE_MODELS_PATH, OPENAERIALMAP_MOSAIC_TILES_URL, } from "@/config"; -import { OfflinePredictionRequestDialog } from "@/features/start-mapping/components/dialogs/offline-prediction-request-dialog"; +import { OfflinePredictionRequestDialog } from "@/features/offline-predictions/components/dialogs/offline-prediction-request-dialog"; import { GeoJSONStoreFeatures } from "terra-draw"; import { FileUploadIcon } from "@/components/ui/icons"; import { ToolTip } from "@/components/ui/tooltip"; diff --git a/frontend/src/config/env.ts b/frontend/src/config/env.ts index 7c2656fa2..26d67a8d7 100644 --- a/frontend/src/config/env.ts +++ b/frontend/src/config/env.ts @@ -88,4 +88,16 @@ export const ENVS = { .VITE_MAXIMUM_PREDICTION_TOLERANCE, FAIR_MODELS_BASE_PATH: import.meta.env.VITE_FAIR_MODELS_BASE_PATH, OFFSET_STEP: import.meta.env.VITE_OFFSET_STEP, + MAPSWIPE_FIREBASE_API_KEY: import.meta.env.VITE_MAPSWIPE_FIREBASE_API_KEY, + MAPSWIPE_FIREBASE_AUTH_DOMAIN: import.meta.env + .VITE_MAPSWIPE_FIREBASE_AUTH_DOMAIN, + MAPSWIPE_FIREBASE_DATABASE_URL: import.meta.env + .VITE_MAPSWIPE_FIREBASE_DATABASE_URL, + MAPSWIPE_FIREBASE_PROJECT_ID: import.meta.env + .VITE_MAPSWIPE_FIREBASE_PROJECT_ID, + MAPSWIPE_FIREBASE_STORAGE_BUCKET: import.meta.env + .VITE_MAPSWIPE_FIREBASE_STORAGE_BUCKET, + MAPSWIPE_FIREBASE_MESSAGING_SENDER_ID: import.meta.env + .VITE_MAPSWIPE_FIREBASE_MESSAGING_SENDER_ID, + MAPSWIPE_FIREBASE_APP_ID: import.meta.env.VITE_MAPSWIPE_FIREBASE_APP_ID, }; diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts index 824068709..ba66ddbdd 100644 --- a/frontend/src/config/index.ts +++ b/frontend/src/config/index.ts @@ -490,3 +490,17 @@ export const OFFSET_STEP: number = parseIntEnv(ENVS.OFFSET_STEP, 0.5); * Distance of the elements from the navbar in px for dropdowns and popups on the start mapping page. */ export const ELEMENT_DISTANCE_FROM_NAVBAR: number = 10; + +/** + * The configuration for Firebase. + * This is used to initialize Firebase in the application. + */ +export const firebaseConfig = { + apiKey: ENVS.MAPSWIPE_FIREBASE_API_KEY, + authDomain: ENVS.MAPSWIPE_FIREBASE_AUTH_DOMAIN, + databaseURL: ENVS.MAPSWIPE_FIREBASE_DATABASE_URL, + projectId: ENVS.MAPSWIPE_FIREBASE_PROJECT_ID, + storageBucket: ENVS.MAPSWIPE_FIREBASE_STORAGE_BUCKET, + messagingSenderId: ENVS.MAPSWIPE_FIREBASE_MESSAGING_SENDER_ID, + appId: ENVS.MAPSWIPE_FIREBASE_APP_ID, +}; diff --git a/frontend/src/features/mapswipe/components/mapswipe-project-creation-dialog.tsx b/frontend/src/features/mapswipe/components/mapswipe-project-creation-dialog.tsx new file mode 100644 index 000000000..e53436bdb --- /dev/null +++ b/frontend/src/features/mapswipe/components/mapswipe-project-creation-dialog.tsx @@ -0,0 +1,292 @@ +import { useState } from "react"; +import { Dialog } from "@/components/ui/dialog"; +import { Divider } from "@/components/ui/divider"; +import { Input, TextArea } from "@/components/ui/form"; +import { Button } from "@/components/ui/button"; +import { ButtonVariant, INPUT_TYPES } from "@/enums"; +import { TOfflinePrediction } from "@/types"; +import { BASE_API_URL, MATOMO_APP_DOMAIN } from "@/config"; +import { APPLICATION_ROUTES } from "@/constants"; +import { API_ENDPOINTS } from "@/services"; +import { MapswipeProjectCreationuccess } from "./mapswipe-project-success-dialog"; +import { useDialog } from "@/hooks/use-dialog"; +import { useFirebase } from "@/hooks/use-firebase"; +import { showErrorToast } from "@/utils"; + +const DESCRIPTION_MAX_LENGTH = 500; +const DESCRIPTION_MIN_LENGTH = 10; +const PROJECT_TOPIC_MAX_LENGTH = 50; +const PROJECT_TOPIC_MIN_LENGTH = 5; +const PROJECT_REGION_MAX_LENGTH = 50; +const PROJECT_REGION_MIN_LENGTH = 5; + +export const CreateMapswipeProjectDialog = ({ + isOpened, + closeDialog, + predictionResult, +}: { + isOpened: boolean; + closeDialog: () => void; + predictionResult: TOfflinePrediction; +}) => { + const { + isOpened: isSuccessDialogOpened, + closeDialog: closeSuccessDialog, + openDialog: openSuccessDialog, + } = useDialog(); + + const { pushToDatabase } = useFirebase(); + const [loading, setLoading] = useState(false); + + const DEFAULT_FORMDATA = { + topic: "", + region: "", + details: "", + organisation: "HOT", + visibility: "Public", + tutorial: "tutorial_-MQsj5VWpNcJxCTVTOyH", + infoUrl: + MATOMO_APP_DOMAIN + + APPLICATION_ROUTES.MODELS + + "/" + + predictionResult.config.model_id, + verification: "3", + groupSize: 25, + inputGeometryUrl: + BASE_API_URL + + API_ENDPOINTS.DOWNLOAD_PREDICTION_LABELS_FILE(predictionResult.id), + tileServiceUrl: predictionResult.config.source, + }; + const [form, setForm] = useState(DEFAULT_FORMDATA); + + const updateField = (key: keyof typeof form, value: string) => { + setForm((prev) => ({ ...prev, [key]: value })); + }; + + const [descriptionIsValid, setDescriptionIsValid] = useState({ + valid: + form.details.length >= DESCRIPTION_MIN_LENGTH && + form.details.length <= DESCRIPTION_MAX_LENGTH, + message: "", + }); + + const [topicIsValid, setTopicIsValid] = useState({ + valid: + form.topic.length >= PROJECT_TOPIC_MIN_LENGTH && + form.topic.length <= PROJECT_TOPIC_MAX_LENGTH, + message: "", + }); + + const [regionIsValid, setRegionIsValid] = useState({ + valid: + form.region.length >= PROJECT_REGION_MIN_LENGTH && + form.region.length <= PROJECT_REGION_MAX_LENGTH, + message: "", + }); + + const handleCloseDialog = () => { + closeDialog(); + setLoading(false); + setForm(DEFAULT_FORMDATA); + setDescriptionIsValid({ + valid: + form.details.length >= DESCRIPTION_MIN_LENGTH && + form.details.length <= DESCRIPTION_MAX_LENGTH, + message: "", + }); + setTopicIsValid({ + valid: + form.topic.length >= PROJECT_TOPIC_MIN_LENGTH && + form.topic.length <= PROJECT_TOPIC_MAX_LENGTH, + message: "", + }); + setRegionIsValid({ + valid: + form.region.length >= PROJECT_REGION_MIN_LENGTH && + form.region.length <= PROJECT_REGION_MAX_LENGTH, + message: "", + }); + }; + + const handleProjectCreation = async () => { + try { + setLoading(true); + const newProjectDraftsRef = await pushToDatabase(); + if (newProjectDraftsRef.key) { + handleCloseDialog(); + openSuccessDialog(); + // send actual project data to the database here + // patch this prediction result with the mapswipe project id + } else { + showErrorToast("Failed to create MapSwipe project."); + } + } catch (error) { + setLoading(false); + showErrorToast(error); + } + }; + + return ( + <> + + + + +
+
+ updateField("topic", e.target.value)} + showBorder + maxLength={PROJECT_TOPIC_MAX_LENGTH} + labelWithTooltip + toolTipContent="Starts with the project type title by MapSwipe convention (Conflate ..., Compare ..., etc)" + placeholder="Conflate fAIr buildings" + minLength={PROJECT_TOPIC_MIN_LENGTH} + validationStateUpdateCallback={setTopicIsValid} + isValid={form.topic.length > 0 && topicIsValid.valid} + /> + updateField("region", e.target.value)} + showBorder + maxLength={PROJECT_REGION_MAX_LENGTH} + labelWithTooltip + toolTipContent="The region where the project is located, e.g., Banepa, Nepal" + placeholder="Banepa, Nepal" + minLength={PROJECT_REGION_MIN_LENGTH} + validationStateUpdateCallback={setRegionIsValid} + isValid={form.region.length > 0 && regionIsValid.valid} + /> +
+ +