diff --git a/frontend/src/app/providers/models-provider.tsx b/frontend/src/app/providers/models-provider.tsx index a6c66ab5..30a8a94c 100644 --- a/frontend/src/app/providers/models-provider.tsx +++ b/frontend/src/app/providers/models-provider.tsx @@ -5,16 +5,16 @@ import { TOAST_NOTIFICATIONS, } from "@/constants"; import { BASE_MODELS, TrainingDatasetOption, TrainingType } from "@/enums"; -import { HOT_FAIR_MODEL_CREATION_SESSION_STORAGE_KEY } from "@/config"; +import { HOT_FAIR_MODEL_CREATION_LOCAL_STORAGE_KEY } from "@/config"; import { LngLatBoundsLike } from "maplibre-gl"; import { useCreateTrainingDataset } from "@/features/model-creation/hooks/use-training-datasets"; -import { useGetTrainingDataset } from "@/features/models/hooks/use-dataset"; import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useModelDetails } from "@/features/models/hooks/use-models"; import { UseMutationResult } from "@tanstack/react-query"; -import { useSessionStorage } from "@/hooks/use-storage"; +import { useLocalStorage } from "@/hooks/use-storage"; import { + TModelDetails, TTrainingAreaFeature, TTrainingDataset, TTrainingDetails, @@ -26,6 +26,7 @@ import { } from "@/utils"; import React, { createContext, + useCallback, useContext, useEffect, useMemo, @@ -41,6 +42,8 @@ import { useCreateModelTrainingRequest, useUpdateModel, } from "@/features/model-creation/hooks/use-models"; +import axios from "axios"; +import { useAuth } from "./auth-provider"; /** * The names here are the same with the `initialFormState` object keys. @@ -229,6 +232,10 @@ const ModelsContext = createContext<{ handleModelCreationAndUpdate: () => void; handleTrainingDatasetCreation: () => void; validateEditMode: boolean; + isError: boolean; + isPending: boolean; + data: TModelDetails; + isModelOwner: boolean; }>({ formData: initialFormState, setFormData: () => {}, @@ -255,6 +262,10 @@ const ModelsContext = createContext<{ trainingDatasetCreationInProgress: false, handleTrainingDatasetCreation: () => {}, validateEditMode: false, + isPending: false, + isError: false, + data: {} as TModelDetails, + isModelOwner: false, }); export const ModelsProvider: React.FC<{ @@ -262,16 +273,13 @@ export const ModelsProvider: React.FC<{ }> = ({ children }) => { const navigate = useNavigate(); const { pathname } = useLocation(); - const { modelId } = useParams(); - const { getSessionValue, setSessionValue, removeSessionValue } = - useSessionStorage(); - - const storedFormData = getSessionValue( - HOT_FAIR_MODEL_CREATION_SESSION_STORAGE_KEY, - ); + const { modelId, id } = useParams(); + const { setValue, removeValue, getValue } = useLocalStorage(); + const storedFormData = getValue(HOT_FAIR_MODEL_CREATION_LOCAL_STORAGE_KEY); const [formData, setFormData] = useState( storedFormData ? JSON.parse(storedFormData) : initialFormState, ); + const { user, isAuthenticated } = useAuth(); const handleChange = ( field: string, @@ -285,8 +293,8 @@ export const ModelsProvider: React.FC<{ ) => { setFormData((prev) => { const updatedData = { ...prev, [field]: value }; - setSessionValue( - HOT_FAIR_MODEL_CREATION_SESSION_STORAGE_KEY, + setValue( + HOT_FAIR_MODEL_CREATION_LOCAL_STORAGE_KEY, JSON.stringify(updatedData), ); return updatedData; @@ -300,31 +308,43 @@ export const ModelsProvider: React.FC<{ const isEditMode = Boolean(modelId && !pathname.includes("new")); - const { data, isPending, isError } = useModelDetails( - modelId as string, - isEditMode, + const { data, isPending, isError, error } = useModelDetails( + id ?? (modelId as string), + !!id || !!modelId, + 10000, ); - const { - data: trainingDataset, - isPending: trainingDatasetIsPending, - isError: trainingDatasetIsError, - } = useGetTrainingDataset( - Number(data?.dataset), - Boolean(isEditMode && data?.dataset), - ); + const isModelOwner = isAuthenticated && data?.user?.osm_id === user?.osm_id; // Will be used in the route validator component to delay the redirection for a while until the data are retrieved const validateEditMode = formData.selectedTrainingDatasetId !== "" && formData.tmsURL !== ""; - // Fetch and prefill model details useEffect(() => { - if (!isEditMode || isPending || !data) return; - - if (isError) { - navigate(APPLICATION_ROUTES.NOTFOUND); + if (isError && error) { + const currentPath = pathname; + if (axios.isAxiosError(error)) { + navigate(APPLICATION_ROUTES.NOTFOUND, { + state: { + from: currentPath, + error: error.response?.data?.detail, + }, + }); + } else { + const err = error as Error; + navigate(APPLICATION_ROUTES.NOTFOUND, { + state: { + from: currentPath, + error: err.message, + }, + }); + } } + }, [isError, error, navigate]); + + // Fetch and prefill model details and training dataset + useEffect(() => { + if (!isEditMode || isPending || !data || isError) return; handleChange(MODEL_CREATION_FORM_NAME.BASE_MODELS, data.base_model); handleChange( @@ -334,50 +354,36 @@ export const ModelsProvider: React.FC<{ handleChange(MODEL_CREATION_FORM_NAME.MODEL_NAME, data.name ?? ""); handleChange( MODEL_CREATION_FORM_NAME.SELECTED_TRAINING_DATASET_ID, - data.dataset, + data.dataset.id, ); - }, [isEditMode, isError, isPending, data]); - - // Fetch and prefill training dataset - useEffect(() => { - if (!isEditMode || trainingDatasetIsPending || trainingDatasetIsError) - return; handleChange( MODEL_CREATION_FORM_NAME.DATASET_NAME, - trainingDataset.name ?? "", + data.dataset.name ?? "", ); handleChange( MODEL_CREATION_FORM_NAME.TMS_URL, - trainingDataset.source_imagery ?? "", + data.dataset.source_imagery ?? "", ); - }, [ - isEditMode, - trainingDatasetIsPending, - trainingDataset, - trainingDatasetIsError, - ]); + }, [isEditMode, isError, isPending, data]); + + const resetState = () => { + removeValue(HOT_FAIR_MODEL_CREATION_LOCAL_STORAGE_KEY); + setFormData(initialFormState); + }; useEffect(() => { // Cleanup the timeout on component unmount return () => { - removeSessionValue(HOT_FAIR_MODEL_CREATION_SESSION_STORAGE_KEY); if (timeOutRef.current) { clearTimeout(timeOutRef.current); } }; }, []); - const resetState = () => { - removeSessionValue(HOT_FAIR_MODEL_CREATION_SESSION_STORAGE_KEY); - setFormData(initialFormState); - }; - const createNewTrainingRequestMutation = useCreateModelTrainingRequest({ mutationConfig: { onSuccess: () => { showSuccessToast(TOAST_NOTIFICATIONS.trainingRequestSubmittedSuccess); - // Reset the state after 2 second on the model success page. - resetState(); }, onError: (error) => { showErrorToast(error); @@ -405,22 +411,29 @@ export const ModelsProvider: React.FC<{ }, }); - const handleModelCreationOrUpdateSuccess = (modelId: string) => { - showSuccessToast( - isEditMode - ? TOAST_NOTIFICATIONS.modelUpdateSuccess - : TOAST_NOTIFICATIONS.modelCreationSuccess, - ); - navigate(`${getFullPath(MODELS_ROUTES.CONFIRMATION)}?id=${modelId}`); - // Submit the model for training request + const submitTrainingRequest = useCallback(() => { createNewTrainingRequestMutation.mutate({ - model: modelId, + model: modelId as string, input_boundary_width: formData.boundaryWidth, input_contact_spacing: formData.contactSpacing, epochs: formData.epoch, batch_size: formData.batchSize, zoom_level: formData.zoomLevels, }); + }, [formData, modelId]); + + const handleModelCreationOrUpdateSuccess = (id?: string) => { + if (isModelOwner) { + showSuccessToast( + isEditMode + ? TOAST_NOTIFICATIONS.modelUpdateSuccess + : TOAST_NOTIFICATIONS.modelCreationSuccess, + ); + } + + navigate(`${getFullPath(MODELS_ROUTES.CONFIRMATION)}?id=${id ?? modelId}`); + // Submit the model for training request + submitTrainingRequest(); }; const modelCreateMutation = useCreateModel({ @@ -446,19 +459,23 @@ export const ModelsProvider: React.FC<{ modelId: modelId as string, }); - // Confirm that all the training areas labels has been retrieved - const hasLabeledTrainingAreas = - formData.trainingAreas.length > 0 && - formData.trainingAreas.filter( - (aoi: TTrainingAreaFeature) => aoi.properties.label_fetched === null, - ).length === 0; + // Confirm that all the training areas labels have been fetched. + const hasLabeledTrainingAreas = useMemo( + () => + formData.trainingAreas.every( + (aoi: TTrainingAreaFeature) => aoi.properties.label_fetched !== null, + ), + [formData], + ); - // Confirm that all of the training areas has a geometry - const hasAOIsWithGeometry = - formData.trainingAreas.length > 0 && - formData.trainingAreas.filter( - (aoi: TTrainingAreaFeature) => aoi.geometry === null, - ).length === 0; + // Confirm that all of the training areas have a geometry. + const hasAOIsWithGeometry = useMemo( + () => + formData.trainingAreas.every( + (aoi: TTrainingAreaFeature) => aoi.geometry !== null, + ), + [formData], + ); const handleTrainingDatasetCreation = () => { createNewTrainingDatasetMutation.mutate({ @@ -470,7 +487,9 @@ export const ModelsProvider: React.FC<{ createNewTrainingDatasetMutation.isPending; const handleModelCreationAndUpdate = () => { - if (isEditMode) { + // The user is trying to edit their model. + // In this case, send a PATCH request and submit a training request. + if (isEditMode && isModelOwner) { modelUpdateMutation.mutate({ dataset: formData.selectedTrainingDatasetId, name: formData.modelName, @@ -478,6 +497,10 @@ export const ModelsProvider: React.FC<{ base_model: formData.baseModel as BASE_MODELS, modelId: modelId as string, }); + // The user is trying to edit another users model training area and settings. + // In this case, directly submit a training request. + } else if (isEditMode && !isModelOwner) { + handleModelCreationOrUpdateSuccess(); } else { modelCreateMutation.mutate({ dataset: formData.selectedTrainingDatasetId, @@ -496,7 +519,6 @@ export const ModelsProvider: React.FC<{ hasLabeledTrainingAreas, hasAOIsWithGeometry, formData, - resetState, createNewTrainingRequestMutation, isEditMode, modelId, @@ -505,6 +527,11 @@ export const ModelsProvider: React.FC<{ handleTrainingDatasetCreation, trainingDatasetCreationInProgress, validateEditMode, + resetState, + data, + isPending, + isError, + isModelOwner, }), [ setFormData, @@ -513,15 +540,19 @@ export const ModelsProvider: React.FC<{ createNewTrainingDatasetMutation, hasLabeledTrainingAreas, hasAOIsWithGeometry, - resetState, createNewTrainingRequestMutation, isEditMode, modelId, + resetState, getFullPath, handleModelCreationAndUpdate, handleTrainingDatasetCreation, trainingDatasetCreationInProgress, validateEditMode, + data, + isPending, + isError, + isModelOwner, ], ); diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 69d8e030..cec0af41 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -7,6 +7,7 @@ import { RouterProvider, createBrowserRouter, } from "react-router-dom"; +import { ModelsProvider } from "@/app/providers/models-provider"; const router = createBrowserRouter([ { @@ -71,7 +72,12 @@ const router = createBrowserRouter([ "@/app/routes/models/model-details-card" ); return { - Component: () => , + Component: () => ( + + {" "} + + + ), }; }, }, @@ -333,11 +339,10 @@ const router = createBrowserRouter([ return { Component: AuthenticationCallbackPage }; }, }, - /** * Auth route ends. */ - + /** * 404 route */ diff --git a/frontend/src/app/routes/models/confirmation.tsx b/frontend/src/app/routes/models/confirmation.tsx index bf7ec6d6..85632dce 100644 --- a/frontend/src/app/routes/models/confirmation.tsx +++ b/frontend/src/app/routes/models/confirmation.tsx @@ -5,13 +5,22 @@ import { Image } from "@/components/ui/image"; import { Link } from "@/components/ui/link"; import { ModelFormConfirmation } from "@/assets/images"; import { useModelsContext } from "@/app/providers/models-provider"; -import { useSearchParams } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useEffect } from "react"; export const ModelConfirmationPage = () => { const [searchParams] = useSearchParams(); - + const navigate = useNavigate(); const modelId = searchParams.get("id"); - const { isEditMode } = useModelsContext(); + const { isEditMode, resetState } = useModelsContext(); + + // Reset the state on this page. + useEffect(() => { + if (!modelId) { + navigate(APPLICATION_ROUTES.CREATE_NEW_MODEL); + } + resetState(); + }, []); return (
{ Model {modelId} is {isEditMode ? "Updated" : "Created"}!

- {MODELS_CONTENT.modelCreation.confirmation.description} + {isEditMode + ? MODELS_CONTENT.modelCreation.confirmation.updateDescription + : MODELS_CONTENT.modelCreation.confirmation.description}

{ - const { id } = useParams<{ id: string }>(); - const { isOpened: isModelFilesDialogOpened, closeDialog: closeModelFilesDialog, openDialog: openModelFilesDialog, } = useDialog(); - const navigate = useNavigate(); + const { data, isPending, isError } = useModelsContext(); - const { data, isPending, isError, error } = useModelDetails( - id as string, - !!id, - 10000, - ); const { isAuthenticated } = useAuth(); - useEffect(() => { - if (isError && error) { - const currentPath = window.location.pathname; - if (axios.isAxiosError(error)) { - navigate(APPLICATION_ROUTES.NOTFOUND, { - state: { - from: currentPath, - error: error.response?.data?.detail, - }, - }); - } else { - const err = error as Error; - navigate(APPLICATION_ROUTES.NOTFOUND, { - state: { - from: currentPath, - error: err.message, - }, - }); - } - } - }, [isError, error, navigate]); - const { isOpened: isModelEnhancementDialogOpened, closeDialog: closeModelEnhancementDialog, openDialog: openModelEnhancementDialog, } = useDialog(); - const { - isPending: isTrainingDatasetPending, - data: trainingDataset, - isError: isTrainingDatasetError, - } = useGetTrainingDataset(data?.dataset, !!data); - const { isOpened, closeDialog, openDialog } = useDialog(); - if ( - isPending || - isError || - isTrainingDatasetPending || - isTrainingDatasetError - ) { + if (isPending || isError) { return ; } @@ -91,20 +47,20 @@ export const ModelDetailsPage = () => {
@@ -112,9 +68,7 @@ export const ModelDetailsPage = () => { data={data as TModelDetails} openModelFilesDialog={openModelFilesDialog} openTrainingAreaDrawer={openDialog} - isError={isTrainingDatasetError} - isPending={isTrainingDatasetPending} - trainingDataset={trainingDataset as TTrainingDataset} + trainingDataset={data?.dataset} /> { ) : ( )} @@ -168,9 +122,9 @@ export const ModelDetailsPage = () => { modelId={data?.id as string} trainingId={data?.published_training as number} modelOwner={data?.user.username as string} - datasetId={data?.dataset as number} + datasetId={data?.dataset.id as number} baseModel={data?.base_model as string} - tmsUrl={trainingDataset.source_imagery} + tmsUrl={data?.dataset?.source_imagery} />
diff --git a/frontend/src/app/routes/start-mapping.tsx b/frontend/src/app/routes/start-mapping.tsx index 425a7f96..c47f1b6f 100644 --- a/frontend/src/app/routes/start-mapping.tsx +++ b/frontend/src/app/routes/start-mapping.tsx @@ -8,20 +8,14 @@ import { FitToBounds, LayerControl, ZoomLevel } from "@/components/map"; import { Head } from "@/components/seo"; import { LngLatBoundsLike } from "maplibre-gl"; import { ModelDetailsPopUp } from "@/features/start-mapping/components"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useDropdownMenu } from "@/hooks/use-dropdown-menu"; import { useGetTMSTileJSON } from "@/features/model-creation/hooks/use-tms-tilejson"; -import { useGetTrainingDataset } from "@/features/models/hooks/use-dataset"; import { useMapInstance } from "@/hooks/use-map-instance"; import { useModelDetails } from "@/features/models/hooks/use-models"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { UserProfile } from "@/components/layout"; -import { - Feature, - TileJSON, - TModelPredictions, - TTrainingDataset, -} from "@/types"; +import { Feature, TileJSON, TModelPredictions } from "@/types"; import { BrandLogoWithDropDown, Legend, @@ -43,7 +37,9 @@ import { ACCEPTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, ALL_MODEL_PREDICTIONS_FILL_LAYER_ID, ALL_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, + HOT_FAIR_MODEL_PREDICTIONS_LOCAL_STORAGE_KEY, } from "@/config"; +import { useLocalStorage } from "@/hooks/use-storage"; export type TDownloadOptions = { name: string; @@ -75,24 +71,23 @@ export const StartMappingPage = () => { const { isError, - isPending, + isPending: modelInfoRequestIspending, data: modelInfo, error, } = useModelDetails(modelId as string, !!modelId); - const { - data: trainingDataset, - isPending: trainingDatasetIsPending, - isError: trainingDatasetIsError, - } = useGetTrainingDataset(modelInfo?.dataset as number, !isPending); - - const tileJSONURL = extractTileJSONURL(trainingDataset?.source_imagery ?? ""); + const tileJSONURL = useMemo( + () => + modelInfo?.dataset?.source_imagery + ? extractTileJSONURL(modelInfo?.dataset?.source_imagery) + : undefined, + [modelInfo?.dataset?.source_imagery], + ); - const { - data: oamTileJSON, - isError: oamTileJSONIsError, - error: oamTileJSONError, - } = useGetTMSTileJSON(tileJSONURL); + const { data: oamTileJSON, isError: oamTileJSONIsError } = useGetTMSTileJSON( + tileJSONURL as string, + !!tileJSONURL, + ); useEffect(() => { if (isError) { @@ -117,18 +112,38 @@ export const StartMappingPage = () => { [SEARCH_PARAMS.area]: searchParams.get(SEARCH_PARAMS.area) || 4, }; }); + const { setValue, getValue } = useLocalStorage(); - // Todo - move to local storage. - const [modelPredictions, setModelPredictions] = useState({ - all: [], + const emptyPredictionState = { accepted: [], rejected: [], - }); + all: [], + }; + const [modelPredictions, setModelPredictions] = useState( + () => { + const savedPredictions = getValue( + HOT_FAIR_MODEL_PREDICTIONS_LOCAL_STORAGE_KEY(modelId as string), + ); + return savedPredictions + ? JSON.parse(savedPredictions) + : emptyPredictionState; + }, + ); + + useEffect(() => { + setValue( + HOT_FAIR_MODEL_PREDICTIONS_LOCAL_STORAGE_KEY(modelId as string), + JSON.stringify(modelPredictions), + ); + }, [modelPredictions, modelId]); - const modelPredictionsExist = - modelPredictions.accepted.length > 0 || - modelPredictions.rejected.length > 0 || - modelPredictions.all.length > 0; + const modelPredictionsExist = useMemo(() => { + return ( + modelPredictions.accepted.length > 0 || + modelPredictions.rejected.length > 0 || + modelPredictions.all.length > 0 + ); + }, [modelPredictions]); const updateQuery = useCallback( (newParams: TQueryParams) => { @@ -154,47 +169,50 @@ export const StartMappingPage = () => { const popupAnchorId = "model-details"; - const mapLayers = [ - ...(modelPredictions.accepted.length > 0 - ? [ - { - value: - START_MAPPING_PAGE_CONTENT.map.controls.legendControl - .acceptedPredictions, - subLayers: [ - ACCEPTED_MODEL_PREDICTIONS_FILL_LAYER_ID, - ACCEPTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, - ], - }, - ] - : []), - ...(modelPredictions.rejected.length > 0 - ? [ - { - value: - START_MAPPING_PAGE_CONTENT.map.controls.legendControl - .rejectedPredictions, - subLayers: [ - REJECTED_MODEL_PREDICTIONS_FILL_LAYER_ID, - REJECTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, - ], - }, - ] - : []), - ...(modelPredictions.all.length > 0 - ? [ - { - value: - START_MAPPING_PAGE_CONTENT.map.controls.legendControl - .predictionResults, - subLayers: [ - ALL_MODEL_PREDICTIONS_FILL_LAYER_ID, - ALL_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, - ], - }, - ] - : []), - ]; + const mapLayers = useMemo( + () => [ + ...(modelPredictions.accepted.length > 0 + ? [ + { + value: + START_MAPPING_PAGE_CONTENT.map.controls.legendControl + .acceptedPredictions, + subLayers: [ + ACCEPTED_MODEL_PREDICTIONS_FILL_LAYER_ID, + ACCEPTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, + ], + }, + ] + : []), + ...(modelPredictions.rejected.length > 0 + ? [ + { + value: + START_MAPPING_PAGE_CONTENT.map.controls.legendControl + .rejectedPredictions, + subLayers: [ + REJECTED_MODEL_PREDICTIONS_FILL_LAYER_ID, + REJECTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, + ], + }, + ] + : []), + ...(modelPredictions.all.length > 0 + ? [ + { + value: + START_MAPPING_PAGE_CONTENT.map.controls.legendControl + .predictionResults, + subLayers: [ + ALL_MODEL_PREDICTIONS_FILL_LAYER_ID, + ALL_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, + ], + }, + ] + : []), + ], + [modelPredictions], + ); const handleAllFeaturesDownload = useCallback(async () => { geoJSONDowloader( @@ -206,7 +224,7 @@ export const StartMappingPage = () => { ...modelPredictions.all, ], }, - `all_predictions_${modelInfo.dataset}`, + `all_predictions_${modelInfo.dataset.id}`, ); showSuccessToast(TOAST_NOTIFICATIONS.startMapping.fileDownloadSuccess); }, [modelPredictions, modelInfo]); @@ -214,23 +232,22 @@ export const StartMappingPage = () => { const handleAcceptedFeaturesDownload = useCallback(async () => { geoJSONDowloader( { type: "FeatureCollection", features: modelPredictions.accepted }, - `accepted_predictions_${modelInfo.dataset}`, + `accepted_predictions_${modelInfo.dataset.id}`, ); showSuccessToast(TOAST_NOTIFICATIONS.startMapping.fileDownloadSuccess); }, [modelPredictions, modelInfo]); const handleFeaturesDownloadToJOSM = useCallback( (features: Feature[]) => { - if (!map || !trainingDataset?.name || !trainingDataset?.source_imagery) - return; + if (!map || !modelInfo?.dataset) return; openInJOSM( - trainingDataset.name, - trainingDataset.source_imagery, + modelInfo.dataset.name, + modelInfo.dataset.source_imagery, features, true, ); }, - [map, oamTileJSON, trainingDataset], + [map, modelInfo], ); const handleAllFeaturesDownloadToJOSM = useCallback(() => { @@ -288,18 +305,14 @@ export const StartMappingPage = () => { }, [setShowModelDetailsPopup]); const clearPredictions = useCallback(() => { - setModelPredictions({ - accepted: [], - rejected: [], - all: [], - }); + setModelPredictions(emptyPredictionState); }, [setModelPredictions]); return ( <> - {/* Mobile dialog */}
+ {/* Mobile dialog */} { updateQuery={updateQuery} modelDetailsPopupIsActive={showModelDetailsPopup} clearPredictions={clearPredictions} - trainingDataset={trainingDataset as TTrainingDataset} currentZoom={currentZoom} modelInfo={modelInfo} />
{/* Model Details Popup */} - {modelInfo && ( - setShowModelDetailsPopup(false)} - anchor={popupAnchorId} - model={modelInfo} - trainingDataset={trainingDataset} - trainingDatasetIsPending={trainingDatasetIsPending} - trainingDatasetIsError={trainingDatasetIsError} - /> - )} + setShowModelDetailsPopup(false)} + anchor={popupAnchorId} + modelInfo={modelInfo} + modelInfoRequestIsPending={modelInfoRequestIspending} + modelInfoRequestIsError={isError} + /> {/* Web Header */} { handleModelDetailsPopup={handleModelDetailsPopup} downloadOptions={downloadOptions} clearPredictions={clearPredictions} - trainingDataset={trainingDataset as TTrainingDataset} currentZoom={currentZoom} />
@@ -381,12 +389,11 @@ export const StartMappingPage = () => {
{/* Map Component */} { layers={mapLayers} tmsBounds={oamTileJSON?.bounds as LngLatBoundsLike} trainingId={modelInfo?.published_training} + modelInfoRequestIsPending={modelInfoRequestIspending} />
diff --git a/frontend/src/components/map/layers/open-aerial-map.tsx b/frontend/src/components/map/layers/open-aerial-map.tsx index 95f8b9e9..37219210 100644 --- a/frontend/src/components/map/layers/open-aerial-map.tsx +++ b/frontend/src/components/map/layers/open-aerial-map.tsx @@ -1,6 +1,6 @@ import { Map } from "maplibre-gl"; import { TMS_LAYER_ID, TMS_SOURCE_ID } from "@/config"; -import { useMapLayers } from "@/hooks/use-map-layer"; +import { useEffect } from "react"; export const OpenAerialMap = ({ tileJSONURL, @@ -9,26 +9,24 @@ export const OpenAerialMap = ({ tileJSONURL?: string; map: Map | null; }) => { - useMapLayers( - [ - { + useEffect(() => { + if (!map) return; + if (!map.getSource(TMS_SOURCE_ID)) { + map.addSource(TMS_SOURCE_ID, { + type: "raster", + url: tileJSONURL, + tileSize: 256, + }); + } + if (!map.getLayer(TMS_LAYER_ID)) { + map.addLayer({ id: TMS_LAYER_ID, type: "raster", source: TMS_SOURCE_ID, layout: { visibility: "visible" }, - }, - ], - [ - { - id: TMS_SOURCE_ID, - spec: { - type: "raster", - url: tileJSONURL, - tileSize: 256, - }, - }, - ], - map, - ); + }); + } + }, [map, tileJSONURL]); + return null; }; diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts index 81c9cde3..e09058b8 100644 --- a/frontend/src/config/index.ts +++ b/frontend/src/config/index.ts @@ -114,9 +114,9 @@ export const HOT_FAIR_LOGIN_SUCCESSFUL_SESSION_KEY: string = /** * The key used to store the model form data in session storage to preserve the state incase the user * visits ID Editor or JOSM to map a training area. - * Session storage is used to allow users to be able to open fAIr on a new tab and start on a clean slate. + * Local storage is used to withstand reloads and network issues. */ -export const HOT_FAIR_MODEL_CREATION_SESSION_STORAGE_KEY: string = +export const HOT_FAIR_MODEL_CREATION_LOCAL_STORAGE_KEY: string = "__hot_fair_model_creation_formdata"; /** @@ -126,10 +126,11 @@ export const HOT_FAIR_BANNER_LOCAL_STORAGE_KEY: string = "__hot_fair_banner_closed"; /** - * The key used to store the model predictions in the session storage for the application. + * The key used to store the predictions for the specific model in the users local storage. */ -export const HOT_FAIR_MODEL_PREDICTIONS_SESSION_STORAGE_KEY: string = - "__hot_fair_model_predictions"; +export const HOT_FAIR_MODEL_PREDICTIONS_LOCAL_STORAGE_KEY = ( + modelId: string, +): string => `__hot_fair_model_predictions_for_model_${modelId}`; // ============================================================================================================================== // Training Area Configurations @@ -190,8 +191,10 @@ export const MAX_ACCEPTABLE_POLYGON_IN_TRAINING_AREA_GEOJSON_FILE: number = /** * The maximum zoom level for the map. + * Model predictions require a max zoom of 22. + * 21 is used here because 1 is already added to the 'currentZoom' in the useMapInstance() hook. */ -export const MAX_ZOOM_LEVEL: number = parseIntEnv(ENVS.MAX_ZOOM_LEVEL, 22); +export const MAX_ZOOM_LEVEL: number = parseIntEnv(ENVS.MAX_ZOOM_LEVEL, 21); /** * The minimum zoom level for the map before the prediction components can be activated. diff --git a/frontend/src/constants/ui-contents/models-content.ts b/frontend/src/constants/ui-contents/models-content.ts index 1e852fe2..aba34e98 100644 --- a/frontend/src/constants/ui-contents/models-content.ts +++ b/frontend/src/constants/ui-contents/models-content.ts @@ -155,6 +155,8 @@ export const MODELS_CONTENT: TModelsContent = { }, description: "Your created model was successful, and it is now undergoing a training.", + updateDescription: + "Model update was successful, and it is now undergoing a training.", }, trainingSettings: { form: { diff --git a/frontend/src/features/model-creation/api/factory.ts b/frontend/src/features/model-creation/api/factory.ts index 690cbe99..d83627a3 100644 --- a/frontend/src/features/model-creation/api/factory.ts +++ b/frontend/src/features/model-creation/api/factory.ts @@ -9,6 +9,7 @@ import { getTrainingDatasetLabels, getTrainingDatasets, } from "@/features/model-creation/api/get-trainings"; +import { QUERY_KEYS } from "@/services"; export const getTrainingDatasetsQueryOptions = (searchQuery: string) => { return queryOptions({ @@ -22,7 +23,7 @@ export const getTrainingAreasQueryOptions = ( offset: number, ) => { return queryOptions({ - queryKey: ["training-areas", datasetId, offset], + queryKey: [QUERY_KEYS.TRAINING_AREAS(datasetId, offset)], queryFn: () => getTrainingAreas(datasetId, offset), placeholderData: keepPreviousData, }); diff --git a/frontend/src/features/model-creation/components/progress-bar.tsx b/frontend/src/features/model-creation/components/progress-bar.tsx index a5300297..6f9c0683 100644 --- a/frontend/src/features/model-creation/components/progress-bar.tsx +++ b/frontend/src/features/model-creation/components/progress-bar.tsx @@ -3,6 +3,7 @@ import { cn } from "@/utils"; import { useEffect, useRef } from "react"; import { useModelsContext } from "@/app/providers/models-provider"; import { useNavigate } from "react-router-dom"; +import { MODELS_ROUTES } from "@/constants"; type ProgressBarProps = { currentPath: string; @@ -16,7 +17,7 @@ const ProgressBar: React.FC = ({ pages, }) => { const navigate = useNavigate(); - const { getFullPath } = useModelsContext(); + const { getFullPath, isModelOwner } = useModelsContext(); const activeStepRef = useRef(null); const containerRef = useRef(null); @@ -43,13 +44,23 @@ const ProgressBar: React.FC = ({ > {pages.map((step, index) => { const activeStep = currentPath.includes(step.path); + // Disable the confirmation page button from been clickable. const isLastPage = index === pages.length - 1; + // Disable other buttons when the user is on the confirmation page. + const isConfirmationPage = currentPath.includes( + MODELS_ROUTES.CONFIRMATION, + ); + // Disable the model details and training dataset if the user is not the owner of the model + const disableButton = + isLastPage || + isConfirmationPage || + (!isModelOwner && [0, 1].includes(index)); return (