From ba5036007c12d7cd265bbee306923cd5c1388aaf Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Tue, 25 Feb 2025 17:39:47 +0100 Subject: [PATCH 1/8] feat: componentized prediction layers + persist in local storage --- frontend/src/app/routes/start-mapping.tsx | 131 ++++++++------ .../components/map/layers/open-aerial-map.tsx | 34 ++-- frontend/src/config/index.ts | 7 +- .../map/layers/accepted-prediction-layer.tsx | 65 +++++++ .../map/layers/all-prediction-layer.tsx | 68 +++++++ .../map/layers/rejected-prediction-layer.tsx | 67 +++++++ .../start-mapping/components/map/map.tsx | 166 +++--------------- 7 files changed, 320 insertions(+), 218 deletions(-) create mode 100644 frontend/src/features/start-mapping/components/map/layers/accepted-prediction-layer.tsx create mode 100644 frontend/src/features/start-mapping/components/map/layers/all-prediction-layer.tsx create mode 100644 frontend/src/features/start-mapping/components/map/layers/rejected-prediction-layer.tsx diff --git a/frontend/src/app/routes/start-mapping.tsx b/frontend/src/app/routes/start-mapping.tsx index 4c11945e..95510d70 100644 --- a/frontend/src/app/routes/start-mapping.tsx +++ b/frontend/src/app/routes/start-mapping.tsx @@ -8,7 +8,7 @@ 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"; @@ -43,7 +43,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; @@ -117,18 +119,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; + }, + ); - const modelPredictionsExist = - modelPredictions.accepted.length > 0 || - modelPredictions.rejected.length > 0 || - modelPredictions.all.length > 0; + useEffect(() => { + setValue( + HOT_FAIR_MODEL_PREDICTIONS_LOCAL_STORAGE_KEY(modelId as string), + JSON.stringify(modelPredictions), + ); + }, [modelPredictions, modelId]); + + const modelPredictionsExist = useMemo(() => { + return ( + modelPredictions.accepted.length > 0 || + modelPredictions.rejected.length > 0 || + modelPredictions.all.length > 0 + ); + }, [modelPredictions]); const updateQuery = useCallback( (newParams: TQueryParams) => { @@ -154,47 +176,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( @@ -288,11 +313,7 @@ export const StartMappingPage = () => { }, [setShowModelDetailsPopup]); const clearPredictions = useCallback(() => { - setModelPredictions({ - accepted: [], - rejected: [], - all: [], - }); + setModelPredictions(emptyPredictionState); }, [setModelPredictions]); return ( diff --git a/frontend/src/components/map/layers/open-aerial-map.tsx b/frontend/src/components/map/layers/open-aerial-map.tsx index 95f8b9e9..3322f4c3 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]); + return null; }; diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts index 81c9cde3..5c2c247b 100644 --- a/frontend/src/config/index.ts +++ b/frontend/src/config/index.ts @@ -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 diff --git a/frontend/src/features/start-mapping/components/map/layers/accepted-prediction-layer.tsx b/frontend/src/features/start-mapping/components/map/layers/accepted-prediction-layer.tsx new file mode 100644 index 00000000..30c97d33 --- /dev/null +++ b/frontend/src/features/start-mapping/components/map/layers/accepted-prediction-layer.tsx @@ -0,0 +1,65 @@ +import { + ACCEPTED_MODEL_PREDICTIONS_FILL_LAYER_ID, + ACCEPTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, + ACCEPTED_MODEL_PREDICTIONS_SOURCE_ID, +} from "@/config"; +import { Feature, GeoJSONType } from "@/types"; +import { GeoJSONSource, Map } from "maplibre-gl"; +import { useEffect, useMemo } from "react"; + +export const AcceptedPredictionsLayer = ({ + map, + features, +}: { + map: Map | null; + features: Feature[]; +}) => { + const geoJsonData = useMemo( + () => ({ + type: "FeatureCollection", + features: features, + }), + [features], + ); + + useEffect(() => { + if (!map) return; + map.addSource(ACCEPTED_MODEL_PREDICTIONS_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: ACCEPTED_MODEL_PREDICTIONS_FILL_LAYER_ID, + type: "fill", + source: ACCEPTED_MODEL_PREDICTIONS_SOURCE_ID, + paint: { + "fill-color": "#23C16B", + "fill-opacity": 0.2, + }, + layout: { visibility: "visible" }, + }); + + map.addLayer({ + id: ACCEPTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, + type: "line", + source: ACCEPTED_MODEL_PREDICTIONS_SOURCE_ID, + paint: { + "line-color": "#23C16B", + "line-width": 2, + }, + layout: { visibility: "visible" }, + }); + }, [map]); + + useEffect(() => { + if (!map || !features) return; + const source = map.getSource( + ACCEPTED_MODEL_PREDICTIONS_SOURCE_ID, + ) as GeoJSONSource; + if (source) { + source.setData(geoJsonData as GeoJSONType); + } + }, [map, geoJsonData]); + + return null; +}; diff --git a/frontend/src/features/start-mapping/components/map/layers/all-prediction-layer.tsx b/frontend/src/features/start-mapping/components/map/layers/all-prediction-layer.tsx new file mode 100644 index 00000000..6e52ae37 --- /dev/null +++ b/frontend/src/features/start-mapping/components/map/layers/all-prediction-layer.tsx @@ -0,0 +1,68 @@ +import { + ALL_MODEL_PREDICTIONS_FILL_LAYER_ID, + ALL_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, + ALL_MODEL_PREDICTIONS_SOURCE_ID, +} from "@/config"; +import { Feature, GeoJSONType } from "@/types"; +import { GeoJSONSource, Map } from "maplibre-gl"; +import { useEffect, useMemo } from "react"; + +type AllPredictionsLayerProps = { + map: Map | null; + features: Feature[]; +}; + +export const AllPredictionsLayer = ({ + map, + features, +}: AllPredictionsLayerProps) => { + const geoJsonData = useMemo( + () => ({ + type: "FeatureCollection", + features: features, + }), + [features], + ); + + useEffect(() => { + if (!map) return; + + map.addSource(ALL_MODEL_PREDICTIONS_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: ALL_MODEL_PREDICTIONS_FILL_LAYER_ID, + type: "fill", + source: ALL_MODEL_PREDICTIONS_SOURCE_ID, + paint: { + "fill-color": "#A243DC", + "fill-opacity": 0.2, + }, + layout: { visibility: "visible" }, + }); + + map.addLayer({ + id: ALL_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, + type: "line", + source: ALL_MODEL_PREDICTIONS_SOURCE_ID, + paint: { + "line-color": "#A243DC", + "line-width": 2, + }, + layout: { visibility: "visible" }, + }); + }, [map]); + + useEffect(() => { + if (!map || !features) return; + const source = map.getSource( + ALL_MODEL_PREDICTIONS_SOURCE_ID, + ) as GeoJSONSource; + if (source) { + source.setData(geoJsonData as GeoJSONType); + } + }, [map, geoJsonData]); + + return null; +}; diff --git a/frontend/src/features/start-mapping/components/map/layers/rejected-prediction-layer.tsx b/frontend/src/features/start-mapping/components/map/layers/rejected-prediction-layer.tsx new file mode 100644 index 00000000..96c411b8 --- /dev/null +++ b/frontend/src/features/start-mapping/components/map/layers/rejected-prediction-layer.tsx @@ -0,0 +1,67 @@ +import { + REJECTED_MODEL_PREDICTIONS_FILL_LAYER_ID, + REJECTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, + REJECTED_MODEL_PREDICTIONS_SOURCE_ID, +} from "@/config"; +import { Feature, GeoJSONType } from "@/types"; +import { GeoJSONSource, Map } from "maplibre-gl"; +import { useEffect, useMemo } from "react"; + +export const RejectedPredictionsLayer = ({ + map, + features, +}: { + map: Map | null; + features: Feature[]; +}) => { + const geoJsonData = useMemo( + () => ({ + type: "FeatureCollection", + features: features, + }), + [features], + ); + + useEffect(() => { + if (!map) return; + + map.addSource(REJECTED_MODEL_PREDICTIONS_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + + map.addLayer({ + id: REJECTED_MODEL_PREDICTIONS_FILL_LAYER_ID, + type: "fill", + source: REJECTED_MODEL_PREDICTIONS_SOURCE_ID, + paint: { + "fill-color": "#D63F40", + "fill-opacity": 0.2, + }, + layout: { visibility: "visible" }, + }); + + map.addLayer({ + id: REJECTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, + type: "line", + source: REJECTED_MODEL_PREDICTIONS_SOURCE_ID, + paint: { + "line-color": "#D63F40", + "line-width": 2, + }, + layout: { visibility: "visible" }, + }); + }, [map]); + + useEffect(() => { + if (!map || !features) return; + const source = map.getSource( + REJECTED_MODEL_PREDICTIONS_SOURCE_ID, + ) as GeoJSONSource; + if (source) { + source.setData(geoJsonData as GeoJSONType); + } + }, [map, features]); + + return null; +}; diff --git a/frontend/src/features/start-mapping/components/map/map.tsx b/frontend/src/features/start-mapping/components/map/map.tsx index 48c004af..ba0f5069 100644 --- a/frontend/src/features/start-mapping/components/map/map.tsx +++ b/frontend/src/features/start-mapping/components/map/map.tsx @@ -2,23 +2,20 @@ import PredictedFeatureActionPopup from "@/features/start-mapping/components/fea import useScreenSize from "@/hooks/use-screen-size"; import { ControlsPosition } from "@/enums"; import { extractTileJSONURL, showErrorToast } from "@/utils"; -import { GeoJSONSource, LngLatBoundsLike, Map } from "maplibre-gl"; +import { LngLatBoundsLike, Map } from "maplibre-gl"; import { Legend } from "@/features/start-mapping/components"; import { MapComponent, MapCursorToolTip } from "@/components/map"; import { TOAST_NOTIFICATIONS } from "@/constants"; -import { useMapLayers } from "@/hooks/use-map-layer"; import { useToolTipVisibility } from "@/hooks/use-tooltip-visibility"; import { Dispatch, RefObject, SetStateAction, - useCallback, useEffect, useState, } from "react"; import { - GeoJSONType, TileJSON, TModelPredictionFeature, TModelPredictions, @@ -26,17 +23,15 @@ import { } from "@/types"; import { ACCEPTED_MODEL_PREDICTIONS_FILL_LAYER_ID, - ACCEPTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, - ACCEPTED_MODEL_PREDICTIONS_SOURCE_ID, ALL_MODEL_PREDICTIONS_FILL_LAYER_ID, - ALL_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, - ALL_MODEL_PREDICTIONS_SOURCE_ID, MIN_ZOOM_LEVEL_FOR_START_MAPPING_PREDICTION, MINIMUM_ZOOM_LEVEL_INSTRUCTION_FOR_PREDICTION, REJECTED_MODEL_PREDICTIONS_FILL_LAYER_ID, - REJECTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, - REJECTED_MODEL_PREDICTIONS_SOURCE_ID, } from "@/config"; +import bbox from "@turf/bbox"; +import { AcceptedPredictionsLayer } from "./layers/accepted-prediction-layer"; +import { RejectedPredictionsLayer } from "./layers/rejected-prediction-layer"; +import { AllPredictionsLayer } from "./layers/all-prediction-layer"; export const StartMappingMapComponent = ({ trainingDataset, @@ -94,142 +89,20 @@ export const StartMappingMapComponent = ({ useEffect(() => { if (!map || !tmsBounds || oamTileJSONIsError) return; - map.fitBounds(tmsBounds); - }, [map, tmsBounds, oamTileJSONIsError, oamTileJSON]); - - // Add the map layers - useMapLayers( - // layers - [ - // accepted - { - id: ACCEPTED_MODEL_PREDICTIONS_FILL_LAYER_ID, - type: "fill", - source: ACCEPTED_MODEL_PREDICTIONS_SOURCE_ID, - paint: { - "fill-color": "#23C16B", - "fill-opacity": 0.2, - }, - layout: { visibility: "visible" }, - }, - { - id: ACCEPTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, - type: "line", - source: ACCEPTED_MODEL_PREDICTIONS_SOURCE_ID, - paint: { - "line-color": "#23C16B", - "line-width": 2, - }, - layout: { visibility: "visible" }, - }, - // rejected - { - id: REJECTED_MODEL_PREDICTIONS_FILL_LAYER_ID, - type: "fill", - source: REJECTED_MODEL_PREDICTIONS_SOURCE_ID, - paint: { - "fill-color": "#D63F40", - "fill-opacity": 0.2, - }, - layout: { visibility: "visible" }, - }, - { - id: REJECTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, - type: "line", - source: REJECTED_MODEL_PREDICTIONS_SOURCE_ID, - paint: { - "line-color": "#D63F40", - "line-width": 2, - }, - layout: { visibility: "visible" }, - }, - // all - { - id: ALL_MODEL_PREDICTIONS_FILL_LAYER_ID, - type: "fill", - source: ALL_MODEL_PREDICTIONS_SOURCE_ID, - paint: { - "fill-color": "#A243DC", - "fill-opacity": 0.2, - }, - layout: { visibility: "visible" }, - }, - { - id: ALL_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, - type: "line", - source: ALL_MODEL_PREDICTIONS_SOURCE_ID, - paint: { - "line-color": "#A243DC", - "line-width": 2, - }, - layout: { visibility: "visible" }, - }, - ], - // sources - [ - { - id: ACCEPTED_MODEL_PREDICTIONS_SOURCE_ID, - spec: { - type: "geojson", - data: { type: "FeatureCollection", features: [] }, - }, - }, - { - id: REJECTED_MODEL_PREDICTIONS_SOURCE_ID, - spec: { - type: "geojson", - data: { type: "FeatureCollection", features: [] }, - }, - }, - { - id: ALL_MODEL_PREDICTIONS_SOURCE_ID, - spec: { - type: "geojson", - data: { type: "FeatureCollection", features: [] }, - }, - }, - ], - map, - ); - const updateLayers = useCallback(() => { - if (map) { - if (map?.getSource(ACCEPTED_MODEL_PREDICTIONS_SOURCE_ID)) { - const source = map.getSource( - ACCEPTED_MODEL_PREDICTIONS_SOURCE_ID, - ) as GeoJSONSource; - source.setData({ - type: "FeatureCollection", - features: modelPredictions.accepted, - } as GeoJSONType); - } - - if (map?.getSource(REJECTED_MODEL_PREDICTIONS_SOURCE_ID)) { - const source = map.getSource( - REJECTED_MODEL_PREDICTIONS_SOURCE_ID, - ) as GeoJSONSource; - source.setData({ - type: "FeatureCollection", - features: modelPredictions.rejected, - } as GeoJSONType); - } - - if (map?.getSource(ALL_MODEL_PREDICTIONS_SOURCE_ID)) { - const source = map.getSource( - ALL_MODEL_PREDICTIONS_SOURCE_ID, - ) as GeoJSONSource; - source.setData({ + // if there are predictions that the user hasn't interacted with, zoom to them. + if (modelPredictions.all.length > 0) { + // get the bbox of the features with turf. + map.fitBounds( + bbox({ type: "FeatureCollection", features: modelPredictions.all, - } as GeoJSONType); - } + }) as LngLatBoundsLike, + ); + } else { + map.fitBounds(tmsBounds); } - }, [map, modelPredictions]); - - useEffect(() => { - if (!map) return; - updateLayers(); - }, [map, updateLayers, modelPredictions]); + }, [map, tmsBounds, oamTileJSONIsError, oamTileJSON]); useEffect(() => { if (!map) return; @@ -287,6 +160,15 @@ export const StartMappingMapComponent = ({ basemaps showCurrentZoom={!isSmallViewport} > + + + {showPopup && ( Date: Tue, 25 Feb 2025 17:41:25 +0100 Subject: [PATCH 2/8] feat: fisabled banner on start mapping page --- frontend/src/layouts/root-layout.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/layouts/root-layout.tsx b/frontend/src/layouts/root-layout.tsx index 75c19e96..26b53603 100644 --- a/frontend/src/layouts/root-layout.tsx +++ b/frontend/src/layouts/root-layout.tsx @@ -25,12 +25,13 @@ export const RootLayout = () => { -
- {!pathname.includes(APPLICATION_ROUTES.AUTH_CALLBACK) && } - +
{!pathname.includes(APPLICATION_ROUTES.AUTH_CALLBACK) && !pathname.includes(APPLICATION_ROUTES.START_MAPPING_BASE) && ( - + <> + + + )}
{ >
+ {!pathname.includes(APPLICATION_ROUTES.START_MAPPING_BASE) && !pathname.includes(APPLICATION_ROUTES.AUTH_CALLBACK) &&
}
From d7dde9a8371776da30a2a5cacdb619b44f43d056 Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Wed, 26 Feb 2025 06:34:09 +0100 Subject: [PATCH 3/8] chore: switched to updated model details endpoint + finalized start mapping fixes --- .../src/app/providers/models-provider.tsx | 31 ++----- .../app/routes/models/model-details-card.tsx | 36 +++------ frontend/src/app/routes/start-mapping.tsx | 80 ++++++++----------- .../components/map/layers/open-aerial-map.tsx | 8 +- frontend/src/config/index.ts | 2 +- .../training-area/open-area-map.tsx | 5 +- .../model-creation/hooks/use-models.ts | 2 +- .../model-creation/hooks/use-tms-tilejson.ts | 3 +- frontend/src/features/models/api/factory.ts | 2 + .../features/models/api/update-trainings.ts | 5 +- .../dialogs/training-settings-dialog.tsx | 2 +- .../models/components/model-details-info.tsx | 18 +---- .../src/features/models/hooks/use-models.ts | 5 +- .../start-mapping/components/header.tsx | 17 ++-- .../map/layers/accepted-prediction-layer.tsx | 55 +++++++------ .../map/layers/all-prediction-layer.tsx | 54 +++++++------ .../components/map/layers/index.ts | 3 + .../map/layers/rejected-prediction-layer.tsx | 55 +++++++------ .../start-mapping/components/map/map.tsx | 34 +++++--- .../components/mobile-drawer.tsx | 7 +- .../start-mapping/components/model-action.tsx | 13 ++- .../components/model-details-popup.tsx | 64 +++++---------- frontend/src/types/api.ts | 1 + 23 files changed, 229 insertions(+), 273 deletions(-) create mode 100644 frontend/src/features/start-mapping/components/map/layers/index.ts diff --git a/frontend/src/app/providers/models-provider.tsx b/frontend/src/app/providers/models-provider.tsx index a6c66ab5..5d32fdf4 100644 --- a/frontend/src/app/providers/models-provider.tsx +++ b/frontend/src/app/providers/models-provider.tsx @@ -8,7 +8,6 @@ import { BASE_MODELS, TrainingDatasetOption, TrainingType } from "@/enums"; import { HOT_FAIR_MODEL_CREATION_SESSION_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"; @@ -305,20 +304,11 @@ export const ModelsProvider: React.FC<{ isEditMode, ); - const { - data: trainingDataset, - isPending: trainingDatasetIsPending, - isError: trainingDatasetIsError, - } = useGetTrainingDataset( - Number(data?.dataset), - Boolean(isEditMode && data?.dataset), - ); - // 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 + // Fetch and prefill model details and training dataset useEffect(() => { if (!isEditMode || isPending || !data) return; @@ -334,28 +324,17 @@ 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]); useEffect(() => { // Cleanup the timeout on component unmount diff --git a/frontend/src/app/routes/models/model-details-card.tsx b/frontend/src/app/routes/models/model-details-card.tsx index cef7265c..cd9371fe 100644 --- a/frontend/src/app/routes/models/model-details-card.tsx +++ b/frontend/src/app/routes/models/model-details-card.tsx @@ -7,13 +7,12 @@ import { Image } from "@/components/ui/image"; import { ModelDetailsSkeleton } from "@/features/models/components/skeletons"; import { ModelFilesDialog } from "@/features/models/components/dialogs"; import { StarStackIcon } from "@/components/ui/icons"; -import { TModelDetails, TTrainingDataset } from "@/types"; +import { TModelDetails } from "@/types"; import { TrainingAreaDrawer } from "@/features/models/components/training-area-drawer"; import { TrainingInProgressImage } from "@/assets/images"; import { useAuth } from "@/app/providers/auth-provider"; import { useDialog } from "@/hooks/use-dialog"; import { useEffect } from "react"; -import { useGetTrainingDataset } from "@/features/models/hooks/use-dataset"; import { useModelDetails } from "@/features/models/hooks/use-models"; import { useNavigate, useParams } from "react-router-dom"; import { @@ -69,20 +68,9 @@ export const ModelDetailsPage = () => { 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 +79,20 @@ export const ModelDetailsPage = () => {
@@ -112,9 +100,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 +154,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 95510d70..75fdd31a 100644 --- a/frontend/src/app/routes/start-mapping.tsx +++ b/frontend/src/app/routes/start-mapping.tsx @@ -11,17 +11,11 @@ import { ModelDetailsPopUp } from "@/features/start-mapping/components"; 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, @@ -77,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) { @@ -231,7 +224,7 @@ export const StartMappingPage = () => { ...modelPredictions.all, ], }, - `all_predictions_${modelInfo.dataset}`, + `all_predictions_${modelInfo.dataset.id}`, ); showSuccessToast(TOAST_NOTIFICATIONS.startMapping.fileDownloadSuccess); }, [modelPredictions, modelInfo]); @@ -239,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(() => { @@ -319,8 +311,9 @@ export const StartMappingPage = () => { 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} />
@@ -402,12 +390,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 3322f4c3..7d67729f 100644 --- a/frontend/src/components/map/layers/open-aerial-map.tsx +++ b/frontend/src/components/map/layers/open-aerial-map.tsx @@ -1,5 +1,9 @@ import { Map } from "maplibre-gl"; -import { TMS_LAYER_ID, TMS_SOURCE_ID } from "@/config"; +import { + + TMS_LAYER_ID, + TMS_SOURCE_ID, +} from "@/config"; import { useEffect } from "react"; export const OpenAerialMap = ({ @@ -26,7 +30,7 @@ export const OpenAerialMap = ({ layout: { visibility: "visible" }, }); } - }, [map]); + }, [map, tileJSONURL]); return null; }; diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts index 5c2c247b..601595ab 100644 --- a/frontend/src/config/index.ts +++ b/frontend/src/config/index.ts @@ -192,7 +192,7 @@ export const MAX_ACCEPTABLE_POLYGON_IN_TRAINING_AREA_GEOJSON_FILE: number = /** * The maximum zoom level for the map. */ -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/features/model-creation/components/training-area/open-area-map.tsx b/frontend/src/features/model-creation/components/training-area/open-area-map.tsx index e9df688a..39f8b1aa 100644 --- a/frontend/src/features/model-creation/components/training-area/open-area-map.tsx +++ b/frontend/src/features/model-creation/components/training-area/open-area-map.tsx @@ -22,7 +22,10 @@ const OpenAerialMap = ({ }) => { const { handleChange } = useModelsContext(); - const { isPending, data, isError } = useGetTMSTileJSON(tileJSONURL); + const { isPending, data, isError } = useGetTMSTileJSON( + tileJSONURL, + !!trainingDatasetId, + ); const { data: trainingDataset, isError: trainingDatasetFetchError } = useGetTrainingDataset(trainingDatasetId); diff --git a/frontend/src/features/model-creation/hooks/use-models.ts b/frontend/src/features/model-creation/hooks/use-models.ts index cf16056f..9da393d9 100644 --- a/frontend/src/features/model-creation/hooks/use-models.ts +++ b/frontend/src/features/model-creation/hooks/use-models.ts @@ -58,7 +58,7 @@ export const useUpdateModel = ({ mutationConfig, modelId, }: useUpdateModelOptions) => { - const { refetch: refetchModelDetails } = useModelDetails(modelId); + const { refetch: refetchModelDetails } = useModelDetails(modelId, true); const queryClient = useQueryClient(); const { onSuccess, ...restConfig } = mutationConfig || {}; diff --git a/frontend/src/features/model-creation/hooks/use-tms-tilejson.ts b/frontend/src/features/model-creation/hooks/use-tms-tilejson.ts index 9378e1f2..9e3071ef 100644 --- a/frontend/src/features/model-creation/hooks/use-tms-tilejson.ts +++ b/frontend/src/features/model-creation/hooks/use-tms-tilejson.ts @@ -1,8 +1,9 @@ import { getTMSTileJSONQueryOptions } from "@/features/model-creation/api/factory"; import { useQuery } from "@tanstack/react-query"; -export const useGetTMSTileJSON = (url: string) => { +export const useGetTMSTileJSON = (url: string, enabled: boolean) => { return useQuery({ ...getTMSTileJSONQueryOptions(url), + enabled: enabled, }); }; diff --git a/frontend/src/features/models/api/factory.ts b/frontend/src/features/models/api/factory.ts index f1a832e8..eb9156e1 100644 --- a/frontend/src/features/models/api/factory.ts +++ b/frontend/src/features/models/api/factory.ts @@ -60,12 +60,14 @@ export const getModelsQueryOptions = ({ export const getModelDetailsQueryOptions = ( id: string, refetchInterval: boolean | number, + enabled: boolean, ) => { return queryOptions({ queryKey: [queryKeys.MODEL_DETAILS(id)], queryFn: () => getModelDetails(id), //@ts-expect-error bad type definition refetchInterval: refetchInterval, + enabled: enabled, }); }; diff --git a/frontend/src/features/models/api/update-trainings.ts b/frontend/src/features/models/api/update-trainings.ts index 83e20048..3135734a 100644 --- a/frontend/src/features/models/api/update-trainings.ts +++ b/frontend/src/features/models/api/update-trainings.ts @@ -17,7 +17,10 @@ export const useUpdateTraining = ({ mutationConfig, modelId, }: UseUpdateTrainingOptions) => { - const { refetch: refetchModelDetails } = useModelDetails(String(modelId)); + const { refetch: refetchModelDetails } = useModelDetails( + String(modelId), + true, + ); const { refetch: refetchTrainingHistory } = useTrainingHistory( String(modelId), 0, diff --git a/frontend/src/features/models/components/dialogs/training-settings-dialog.tsx b/frontend/src/features/models/components/dialogs/training-settings-dialog.tsx index 4617165e..311c96b1 100644 --- a/frontend/src/features/models/components/dialogs/training-settings-dialog.tsx +++ b/frontend/src/features/models/components/dialogs/training-settings-dialog.tsx @@ -24,7 +24,7 @@ const ModelTrainingSettingsDialog: React.FC = ({ closeDialog, modelId, }) => { - const { data, isPending, isError } = useModelDetails(modelId); + const { data, isPending, isError } = useModelDetails(modelId, true); const { handleChange, formData, createNewTrainingRequestMutation } = useModelsContext(); diff --git a/frontend/src/features/models/components/model-details-info.tsx b/frontend/src/features/models/components/model-details-info.tsx index b3b04466..f7156830 100644 --- a/frontend/src/features/models/components/model-details-info.tsx +++ b/frontend/src/features/models/components/model-details-info.tsx @@ -19,15 +19,11 @@ const ModelDetailsInfo = ({ openModelFilesDialog, openTrainingAreaDrawer, trainingDataset, - isError, - isPending, }: { data: TModelDetails; openModelFilesDialog: () => void; openTrainingAreaDrawer: () => void; trainingDataset: TTrainingDataset; - isError: boolean; - isPending: boolean; }) => { const { isOpened, openDialog, closeDialog } = useDialog(); const { user, isAuthenticated } = useAuth(); @@ -105,21 +101,15 @@ const ModelDetailsInfo = ({ {MODELS_CONTENT.models.modelsDetailsCard.datasetName} - {isPending ? ( -

- ) : isError ? ( - Error retrieving dataset info - ) : ( -

- {truncateString(trainingDataset?.name, 40)} -

- )} +

+ {truncateString(trainingDataset?.name, 40)} +

{MODELS_CONTENT.models.modelsDetailsCard.datasetId} -

{data?.dataset}

+

{data?.dataset.id}

{ return useQuery({ - ...getModelDetailsQueryOptions(id, refetchInterval), + ...getModelDetailsQueryOptions(id, refetchInterval, enabled), retry: (_, error) => { // When a model is not found, don't retry. //@ts-expect-error bad type definition return error.response?.status !== 404; }, - enabled: enabled, }); }; diff --git a/frontend/src/features/start-mapping/components/header.tsx b/frontend/src/features/start-mapping/components/header.tsx index c8737df5..884caf66 100644 --- a/frontend/src/features/start-mapping/components/header.tsx +++ b/frontend/src/features/start-mapping/components/header.tsx @@ -11,7 +11,7 @@ import { ModelPredictionsTracker } from "@/features/start-mapping/components/mod import { ModelSettings } from "@/features/start-mapping/components/model-settings"; import { SkeletonWrapper } from "@/components/ui/skeleton"; import { TDownloadOptions, TQueryParams } from "@/app/routes/start-mapping"; -import { TModel, TModelPredictions, TTrainingDataset } from "@/types"; +import { TModelDetails, TModelPredictions } from "@/types"; import { ToolTip } from "@/components/ui/tooltip"; import { useDropdownMenu } from "@/hooks/use-dropdown-menu"; import { UserProfile } from "@/components/layout"; @@ -21,8 +21,8 @@ const StartMappingHeader = ({ modelInfo, modelPredictions, modelPredictionsExist, - trainingDatasetIsPending, - trainingDatasetIsError, + modelInfoRequestIsPending, + modelInfoRequestIsError, query, updateQuery, setModelPredictions, @@ -33,13 +33,12 @@ const StartMappingHeader = ({ modelDetailsPopupIsActive, downloadOptions, clearPredictions, - trainingDataset, currentZoom, }: { modelPredictionsExist: boolean; - trainingDatasetIsPending: boolean; - trainingDatasetIsError: boolean; - modelInfo: TModel; + modelInfoRequestIsPending: boolean; + modelInfoRequestIsError: boolean; + modelInfo: TModelDetails; modelPredictions: TModelPredictions; query: TQueryParams; updateQuery: (newParams: TQueryParams) => void; @@ -51,7 +50,6 @@ const StartMappingHeader = ({ modelDetailsPopupIsActive: boolean; downloadOptions: TDownloadOptions; clearPredictions: () => void; - trainingDataset: TTrainingDataset; currentZoom: number; }) => { const { onDropdownHide, onDropdownShow, dropdownIsOpened } = @@ -65,7 +63,7 @@ const StartMappingHeader = ({ return (
@@ -138,7 +136,6 @@ const StartMappingHeader = ({ map={map} disablePrediction={disablePrediction} query={query} - trainingDataset={trainingDataset} currentZoom={currentZoom} modelInfo={modelInfo} /> diff --git a/frontend/src/features/start-mapping/components/map/layers/accepted-prediction-layer.tsx b/frontend/src/features/start-mapping/components/map/layers/accepted-prediction-layer.tsx index 30c97d33..37cf114c 100644 --- a/frontend/src/features/start-mapping/components/map/layers/accepted-prediction-layer.tsx +++ b/frontend/src/features/start-mapping/components/map/layers/accepted-prediction-layer.tsx @@ -24,31 +24,36 @@ export const AcceptedPredictionsLayer = ({ useEffect(() => { if (!map) return; - map.addSource(ACCEPTED_MODEL_PREDICTIONS_SOURCE_ID, { - type: "geojson", - data: { type: "FeatureCollection", features: [] }, - }); - map.addLayer({ - id: ACCEPTED_MODEL_PREDICTIONS_FILL_LAYER_ID, - type: "fill", - source: ACCEPTED_MODEL_PREDICTIONS_SOURCE_ID, - paint: { - "fill-color": "#23C16B", - "fill-opacity": 0.2, - }, - layout: { visibility: "visible" }, - }); - - map.addLayer({ - id: ACCEPTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, - type: "line", - source: ACCEPTED_MODEL_PREDICTIONS_SOURCE_ID, - paint: { - "line-color": "#23C16B", - "line-width": 2, - }, - layout: { visibility: "visible" }, - }); + if (!map.getSource(ACCEPTED_MODEL_PREDICTIONS_SOURCE_ID)) { + map.addSource(ACCEPTED_MODEL_PREDICTIONS_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + } + if (!map.getLayer(ACCEPTED_MODEL_PREDICTIONS_FILL_LAYER_ID)) { + map.addLayer({ + id: ACCEPTED_MODEL_PREDICTIONS_FILL_LAYER_ID, + type: "fill", + source: ACCEPTED_MODEL_PREDICTIONS_SOURCE_ID, + paint: { + "fill-color": "#23C16B", + "fill-opacity": 0.2, + }, + layout: { visibility: "visible" }, + }); + } + if (!map.getLayer(ACCEPTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID)) { + map.addLayer({ + id: ACCEPTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, + type: "line", + source: ACCEPTED_MODEL_PREDICTIONS_SOURCE_ID, + paint: { + "line-color": "#23C16B", + "line-width": 2, + }, + layout: { visibility: "visible" }, + }); + } }, [map]); useEffect(() => { diff --git a/frontend/src/features/start-mapping/components/map/layers/all-prediction-layer.tsx b/frontend/src/features/start-mapping/components/map/layers/all-prediction-layer.tsx index 6e52ae37..f5e948c9 100644 --- a/frontend/src/features/start-mapping/components/map/layers/all-prediction-layer.tsx +++ b/frontend/src/features/start-mapping/components/map/layers/all-prediction-layer.tsx @@ -26,32 +26,38 @@ export const AllPredictionsLayer = ({ useEffect(() => { if (!map) return; + if (!map.getSource(ALL_MODEL_PREDICTIONS_SOURCE_ID)) { + map.addSource(ALL_MODEL_PREDICTIONS_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + } - map.addSource(ALL_MODEL_PREDICTIONS_SOURCE_ID, { - type: "geojson", - data: { type: "FeatureCollection", features: [] }, - }); - map.addLayer({ - id: ALL_MODEL_PREDICTIONS_FILL_LAYER_ID, - type: "fill", - source: ALL_MODEL_PREDICTIONS_SOURCE_ID, - paint: { - "fill-color": "#A243DC", - "fill-opacity": 0.2, - }, - layout: { visibility: "visible" }, - }); + if (!map.getLayer(ALL_MODEL_PREDICTIONS_FILL_LAYER_ID)) { + map.addLayer({ + id: ALL_MODEL_PREDICTIONS_FILL_LAYER_ID, + type: "fill", + source: ALL_MODEL_PREDICTIONS_SOURCE_ID, + paint: { + "fill-color": "#A243DC", + "fill-opacity": 0.2, + }, + layout: { visibility: "visible" }, + }); + } - map.addLayer({ - id: ALL_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, - type: "line", - source: ALL_MODEL_PREDICTIONS_SOURCE_ID, - paint: { - "line-color": "#A243DC", - "line-width": 2, - }, - layout: { visibility: "visible" }, - }); + if (!map.getLayer(ALL_MODEL_PREDICTIONS_OUTLINE_LAYER_ID)) { + map.addLayer({ + id: ALL_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, + type: "line", + source: ALL_MODEL_PREDICTIONS_SOURCE_ID, + paint: { + "line-color": "#A243DC", + "line-width": 2, + }, + layout: { visibility: "visible" }, + }); + } }, [map]); useEffect(() => { diff --git a/frontend/src/features/start-mapping/components/map/layers/index.ts b/frontend/src/features/start-mapping/components/map/layers/index.ts new file mode 100644 index 00000000..1fa43343 --- /dev/null +++ b/frontend/src/features/start-mapping/components/map/layers/index.ts @@ -0,0 +1,3 @@ +export { AcceptedPredictionsLayer } from "./accepted-prediction-layer"; +export { RejectedPredictionsLayer } from "./rejected-prediction-layer"; +export { AllPredictionsLayer } from "./all-prediction-layer"; diff --git a/frontend/src/features/start-mapping/components/map/layers/rejected-prediction-layer.tsx b/frontend/src/features/start-mapping/components/map/layers/rejected-prediction-layer.tsx index 96c411b8..41f29cb6 100644 --- a/frontend/src/features/start-mapping/components/map/layers/rejected-prediction-layer.tsx +++ b/frontend/src/features/start-mapping/components/map/layers/rejected-prediction-layer.tsx @@ -25,32 +25,37 @@ export const RejectedPredictionsLayer = ({ useEffect(() => { if (!map) return; - map.addSource(REJECTED_MODEL_PREDICTIONS_SOURCE_ID, { - type: "geojson", - data: { type: "FeatureCollection", features: [] }, - }); - - map.addLayer({ - id: REJECTED_MODEL_PREDICTIONS_FILL_LAYER_ID, - type: "fill", - source: REJECTED_MODEL_PREDICTIONS_SOURCE_ID, - paint: { - "fill-color": "#D63F40", - "fill-opacity": 0.2, - }, - layout: { visibility: "visible" }, - }); + if (!map.getSource(REJECTED_MODEL_PREDICTIONS_SOURCE_ID)) { + map.addSource(REJECTED_MODEL_PREDICTIONS_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + } - map.addLayer({ - id: REJECTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, - type: "line", - source: REJECTED_MODEL_PREDICTIONS_SOURCE_ID, - paint: { - "line-color": "#D63F40", - "line-width": 2, - }, - layout: { visibility: "visible" }, - }); + if (!map.getLayer(REJECTED_MODEL_PREDICTIONS_FILL_LAYER_ID)) { + map.addLayer({ + id: REJECTED_MODEL_PREDICTIONS_FILL_LAYER_ID, + type: "fill", + source: REJECTED_MODEL_PREDICTIONS_SOURCE_ID, + paint: { + "fill-color": "#D63F40", + "fill-opacity": 0.2, + }, + layout: { visibility: "visible" }, + }); + } + if (!map.getLayer(REJECTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID)) { + map.addLayer({ + id: REJECTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, + type: "line", + source: REJECTED_MODEL_PREDICTIONS_SOURCE_ID, + paint: { + "line-color": "#D63F40", + "line-width": 2, + }, + layout: { visibility: "visible" }, + }); + } }, [map]); useEffect(() => { diff --git a/frontend/src/features/start-mapping/components/map/map.tsx b/frontend/src/features/start-mapping/components/map/map.tsx index ba0f5069..ca48f1e1 100644 --- a/frontend/src/features/start-mapping/components/map/map.tsx +++ b/frontend/src/features/start-mapping/components/map/map.tsx @@ -29,9 +29,11 @@ import { REJECTED_MODEL_PREDICTIONS_FILL_LAYER_ID, } from "@/config"; import bbox from "@turf/bbox"; -import { AcceptedPredictionsLayer } from "./layers/accepted-prediction-layer"; -import { RejectedPredictionsLayer } from "./layers/rejected-prediction-layer"; -import { AllPredictionsLayer } from "./layers/all-prediction-layer"; +import { + AcceptedPredictionsLayer, + RejectedPredictionsLayer, + AllPredictionsLayer, +} from "@/features/start-mapping/components/map/layers"; export const StartMappingMapComponent = ({ trainingDataset, @@ -39,7 +41,6 @@ export const StartMappingMapComponent = ({ setModelPredictions, oamTileJSONIsError, oamTileJSON, - oamTileJSONError, modelPredictionsExist, map, mapContainerRef, @@ -47,10 +48,12 @@ export const StartMappingMapComponent = ({ layers, tmsBounds, trainingId, + modelInfoRequestIsPending, }: { trainingId: number; trainingDataset?: TTrainingDataset; modelPredictions: TModelPredictions; + setModelPredictions: Dispatch< SetStateAction<{ all: TModelPredictionFeature[]; @@ -61,7 +64,6 @@ export const StartMappingMapComponent = ({ oamTileJSONIsError: boolean; oamTileJSON: TileJSON; - oamTileJSONError: any; modelPredictionsExist: boolean; map: Map | null; currentZoom: number; @@ -71,6 +73,7 @@ export const StartMappingMapComponent = ({ subLayers: string[]; }[]; tmsBounds: LngLatBoundsLike; + modelInfoRequestIsPending: boolean; }) => { const tileJSONURL = extractTileJSONURL(trainingDataset?.source_imagery ?? ""); const [showPopup, setShowPopup] = useState(false); @@ -85,10 +88,11 @@ export const StartMappingMapComponent = ({ useEffect(() => { if (!oamTileJSONIsError) return; showErrorToast(undefined, TOAST_NOTIFICATIONS.trainingDataset.error); - }, [oamTileJSONIsError, oamTileJSONError]); + }, [oamTileJSONIsError]); useEffect(() => { - if (!map || !tmsBounds || oamTileJSONIsError) return; + if (!map || !tmsBounds || oamTileJSONIsError || modelInfoRequestIsPending) + return; // if there are predictions that the user hasn't interacted with, zoom to them. if (modelPredictions.all.length > 0) { @@ -102,17 +106,21 @@ export const StartMappingMapComponent = ({ } else { map.fitBounds(tmsBounds); } - }, [map, tmsBounds, oamTileJSONIsError, oamTileJSON]); + }, [ + map, + tmsBounds, + oamTileJSONIsError, + oamTileJSON, + modelInfoRequestIsPending, + ]); useEffect(() => { if (!map) return; - const layerIds = [ ALL_MODEL_PREDICTIONS_FILL_LAYER_ID, ACCEPTED_MODEL_PREDICTIONS_FILL_LAYER_ID, REJECTED_MODEL_PREDICTIONS_FILL_LAYER_ID, ]; - const handleMouseEnter = () => { map.getCanvas().style.cursor = "pointer"; }; @@ -143,10 +151,10 @@ export const StartMappingMapComponent = ({ const showTooltip = currentZoom < MIN_ZOOM_LEVEL_FOR_START_MAPPING_PREDICTION && tooltipVisible; + return ( + {showPopup && ( void; clearPredictions: () => void; - trainingDataset: TTrainingDataset; currentZoom: number; - modelInfo: TModel; + modelInfo: TModelDetails; }) => { const [showDownloadOptions, setShowDownloadOptions] = useState(false); @@ -62,7 +60,6 @@ export const StartMappingMobileDrawer = ({ map={map} disablePrediction={disablePrediction} modelPredictions={modelPredictions} - trainingDataset={trainingDataset} currentZoom={currentZoom} modelInfo={modelInfo} /> diff --git a/frontend/src/features/start-mapping/components/model-action.tsx b/frontend/src/features/start-mapping/components/model-action.tsx index 807ab78d..3f86b81f 100644 --- a/frontend/src/features/start-mapping/components/model-action.tsx +++ b/frontend/src/features/start-mapping/components/model-action.tsx @@ -3,11 +3,10 @@ import { Map } from "maplibre-gl"; import { START_MAPPING_PAGE_CONTENT, TOAST_NOTIFICATIONS } from "@/constants"; import { BBOX, - TModel, + TModelDetails, TModelPredictions, TModelPredictionsConfig, TQueryParams, - TTrainingDataset, } from "@/types"; import { ToolTip } from "@/components/ui/tooltip"; import { useCallback } from "react"; @@ -23,7 +22,6 @@ const ModelAction = ({ map, disablePrediction, query, - trainingDataset, currentZoom, modelInfo, }: { @@ -32,9 +30,8 @@ const ModelAction = ({ map: Map | null; disablePrediction: boolean; query: TQueryParams; - trainingDataset: TTrainingDataset; currentZoom: number; - modelInfo: TModel; + modelInfo: TModelDetails; }) => { const { modelId } = useParams(); @@ -44,11 +41,11 @@ const ModelAction = ({ area_threshold: query[SEARCH_PARAMS.area] as number, use_josm_q: query[SEARCH_PARAMS.useJOSMQ] as boolean, confidence: query[SEARCH_PARAMS.confidenceLevel] as number, - checkpoint: `/mnt/efsmount/data/trainings/dataset_${modelInfo?.dataset}/output/training_${modelInfo?.published_training}/checkpoint${PREDICTION_API_FILE_EXTENSIONS[modelInfo?.base_model as BASE_MODELS]}`, + checkpoint: `/mnt/efsmount/data/trainings/dataset_${modelInfo?.dataset?.id}/output/training_${modelInfo?.published_training}/checkpoint${PREDICTION_API_FILE_EXTENSIONS[modelInfo?.base_model as BASE_MODELS]}`, max_angle_change: 15, model_id: modelId as string, skew_tolerance: 15, - source: trainingDataset?.source_imagery as string, + source: modelInfo?.dataset?.source_imagery as string, zoom_level: currentZoom, bbox: [ map?.getBounds().getWest(), @@ -57,7 +54,7 @@ const ModelAction = ({ map?.getBounds().getNorth(), ] as BBOX, }; - }, [map, query, currentZoom, trainingDataset, modelInfo]); + }, [map, query, currentZoom, modelInfo]); const modelPredictionMutation = useGetModelPredictions({ mutationConfig: { diff --git a/frontend/src/features/start-mapping/components/model-details-popup.tsx b/frontend/src/features/start-mapping/components/model-details-popup.tsx index 0066f472..eac55f0f 100644 --- a/frontend/src/features/start-mapping/components/model-details-popup.tsx +++ b/frontend/src/features/start-mapping/components/model-details-popup.tsx @@ -4,8 +4,7 @@ import { extractDatePart, roundNumber, truncateString } from "@/utils"; import { MobileDrawer } from "@/components/ui/drawer"; import { Popup } from "@/components/ui/popup"; import { SkeletonWrapper } from "@/components/ui/skeleton"; -import { TModelDetails, TTrainingDataset } from "@/types"; -import { useModelDetails } from "@/features/models/hooks/use-models"; +import { TModelDetails } from "@/types"; import { useTrainingDetails } from "@/features/models/hooks/use-training"; import { START_MAPPING_PAGE_CONTENT } from "@/constants"; @@ -13,84 +12,67 @@ const ModelDetailsPopUp = ({ showPopup, anchor, handlePopup, - modelId, - model, - trainingDatasetIsError, - trainingDataset, - trainingDatasetIsPending, + modelInfo, + modelInfoRequestIsError, + modelInfoRequestIsPending, closeMobileDrawer, }: { showPopup: boolean; anchor: string; handlePopup: () => void; - modelId?: string; - model?: TModelDetails; - trainingDataset?: TTrainingDataset; - trainingDatasetIsPending?: boolean; - trainingDatasetIsError?: boolean; + modelInfo?: TModelDetails; + modelInfoRequestIsPending?: boolean; + modelInfoRequestIsError?: boolean; closeMobileDrawer: () => void; }) => { - const { data, isPending, isError } = useModelDetails( - modelId as string, - modelId ? modelId !== undefined : false, - ); - const { data: trainingDetails, isPending: trainingDetailsIsPending, isError: trainingDetailsError, - } = useTrainingDetails( - model?.published_training ?? (data?.published_training as number), - ); + } = useTrainingDetails(modelInfo?.published_training as number); const { isSmallViewport } = useScreenSize(); const popupContent = (

{START_MAPPING_PAGE_CONTENT.modelDetails.popover.modelId}:{" "} - {model?.id ?? data?.id} + {modelInfo?.id}

{START_MAPPING_PAGE_CONTENT.modelDetails.popover.description}:{" "} - - {model?.description ?? data?.description} - + {modelInfo?.description}

{START_MAPPING_PAGE_CONTENT.modelDetails.popover.lastModified}:{" "} - {extractDatePart( - model?.last_modified ?? (data?.last_modified as string), - )} + {extractDatePart(modelInfo?.last_modified as string)}

{START_MAPPING_PAGE_CONTENT.modelDetails.popover.trainingId}:{" "} - - {model?.published_training ?? data?.published_training} - + {modelInfo?.published_training}

{START_MAPPING_PAGE_CONTENT.modelDetails.popover.datasetId}:{" "} - {model?.dataset ?? data?.dataset} + {modelInfo?.dataset.id}

{START_MAPPING_PAGE_CONTENT.modelDetails.popover.datasetName}:{" "} - {trainingDatasetIsError + {modelInfoRequestIsError ? "N/A" - : truncateString(trainingDataset?.name, 40)}{" "} + : truncateString(modelInfo?.dataset?.name, 40)}{" "}

@@ -112,14 +94,12 @@ const ModelDetailsPopUp = ({

{START_MAPPING_PAGE_CONTENT.modelDetails.popover.accuracy}:{" "} - {roundNumber(model?.accuracy ?? (data?.accuracy as number), 2)}% + {roundNumber(modelInfo?.accuracy as number, 2)}%

{START_MAPPING_PAGE_CONTENT.modelDetails.popover.baseModel}:{" "} - - {model?.base_model ?? data?.base_model} - + {modelInfo?.base_model}

@@ -135,7 +115,7 @@ const ModelDetailsPopUp = ({ canClose >
- {!model && isError ? ( + {!modelInfo && modelInfoRequestIsError ? (
{START_MAPPING_PAGE_CONTENT.modelDetails.error}
) : (
@@ -160,7 +140,7 @@ const ModelDetailsPopUp = ({
- {!model && isError ? ( + {!modelInfo && modelInfoRequestIsError ? (
{START_MAPPING_PAGE_CONTENT.modelDetails.error}
) : (
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 46dd7680..f0bbba76 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -70,6 +70,7 @@ export type PaginatedTrainings = { export type TModelDetails = TModel & { description: string; + dataset: TTrainingDataset; }; export type TTrainingAreaFeature = { From f3f73e9ebf377ccc8f3d1ef8091f7082e06ddfe0 Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Wed, 26 Feb 2025 06:57:08 +0100 Subject: [PATCH 4/8] fix: wip-layer ordering when page reloads --- frontend/src/app/router.tsx | 6 ++-- .../components/map/layers/open-aerial-map.tsx | 28 ++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 87102617..844c59d7 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -322,7 +322,7 @@ const router = createBrowserRouter([ */ /** - * Auth route + * Auth route starts. */ { path: APPLICATION_ROUTES.AUTH_CALLBACK, @@ -333,7 +333,9 @@ const router = createBrowserRouter([ return { Component: AuthenticationCallbackPage }; }, }, - + /** + * Auth route ends. + */ /** * 404 route */ diff --git a/frontend/src/components/map/layers/open-aerial-map.tsx b/frontend/src/components/map/layers/open-aerial-map.tsx index 7d67729f..22a0a5e6 100644 --- a/frontend/src/components/map/layers/open-aerial-map.tsx +++ b/frontend/src/components/map/layers/open-aerial-map.tsx @@ -1,6 +1,11 @@ import { Map } from "maplibre-gl"; import { - + ACCEPTED_MODEL_PREDICTIONS_FILL_LAYER_ID, + ACCEPTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, + ALL_MODEL_PREDICTIONS_FILL_LAYER_ID, + ALL_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, + REJECTED_MODEL_PREDICTIONS_FILL_LAYER_ID, + REJECTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, TMS_LAYER_ID, TMS_SOURCE_ID, } from "@/config"; @@ -29,7 +34,28 @@ export const OpenAerialMap = ({ source: TMS_SOURCE_ID, layout: { visibility: "visible" }, }); + + } + /** + * Move all the layers above the OAM. + * This is needed incase the user reloads the page on the start mapping page + * and there are existing predictions in their local storage. + */ + + const layers = [ + ACCEPTED_MODEL_PREDICTIONS_FILL_LAYER_ID, + ACCEPTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, + ALL_MODEL_PREDICTIONS_FILL_LAYER_ID, + ALL_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, + REJECTED_MODEL_PREDICTIONS_FILL_LAYER_ID, + REJECTED_MODEL_PREDICTIONS_OUTLINE_LAYER_ID, + ]; + layers.forEach((layerId) => { + if (map.getLayer(layerId)) { + map.moveLayer(TMS_LAYER_ID, layerId,); + } + }); }, [map, tileJSONURL]); return null; From 9e212f0db9a5aa315f13e4ee56b7b06dc42288b2 Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Wed, 26 Feb 2025 07:24:25 +0100 Subject: [PATCH 5/8] chore: final clean up --- .../components/map/layers/open-aerial-map.tsx | 2 -- .../start-mapping/components/map/map.tsx | 21 +++++++++++-------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/map/layers/open-aerial-map.tsx b/frontend/src/components/map/layers/open-aerial-map.tsx index 6480d835..15a39c60 100644 --- a/frontend/src/components/map/layers/open-aerial-map.tsx +++ b/frontend/src/components/map/layers/open-aerial-map.tsx @@ -28,8 +28,6 @@ export const OpenAerialMap = ({ source: TMS_SOURCE_ID, layout: { visibility: "visible" }, }); - - } }, [map, tileJSONURL]); diff --git a/frontend/src/features/start-mapping/components/map/map.tsx b/frontend/src/features/start-mapping/components/map/map.tsx index ca48f1e1..67bbe9ee 100644 --- a/frontend/src/features/start-mapping/components/map/map.tsx +++ b/frontend/src/features/start-mapping/components/map/map.tsx @@ -169,15 +169,18 @@ export const StartMappingMapComponent = ({ openAerialMap={!modelInfoRequestIsPending} oamTileJSONURL={tileJSONURL} > - - - + {!modelInfoRequestIsPending && + <> + + + + } {showPopup && ( Date: Wed, 26 Feb 2025 07:30:50 +0100 Subject: [PATCH 6/8] chore: added comments to config --- frontend/src/app/router.tsx | 4 ++-- frontend/src/components/map/layers/open-aerial-map.tsx | 6 +----- frontend/src/config/index.ts | 4 +++- frontend/src/features/start-mapping/components/map/map.tsx | 5 +++-- frontend/src/hooks/use-map-instance.ts | 1 + 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 844c59d7..b43ceeb7 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -334,8 +334,8 @@ const router = createBrowserRouter([ }, }, /** - * Auth route ends. - */ + * Auth route ends. + */ /** * 404 route */ diff --git a/frontend/src/components/map/layers/open-aerial-map.tsx b/frontend/src/components/map/layers/open-aerial-map.tsx index 15a39c60..37219210 100644 --- a/frontend/src/components/map/layers/open-aerial-map.tsx +++ b/frontend/src/components/map/layers/open-aerial-map.tsx @@ -1,8 +1,5 @@ import { Map } from "maplibre-gl"; -import { - TMS_LAYER_ID, - TMS_SOURCE_ID, -} from "@/config"; +import { TMS_LAYER_ID, TMS_SOURCE_ID } from "@/config"; import { useEffect } from "react"; export const OpenAerialMap = ({ @@ -29,7 +26,6 @@ export const OpenAerialMap = ({ layout: { visibility: "visible" }, }); } - }, [map, tileJSONURL]); return null; diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts index 601595ab..584efb7e 100644 --- a/frontend/src/config/index.ts +++ b/frontend/src/config/index.ts @@ -191,6 +191,8 @@ 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, 21); @@ -379,7 +381,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; // ============================================================================================================================== diff --git a/frontend/src/features/start-mapping/components/map/map.tsx b/frontend/src/features/start-mapping/components/map/map.tsx index 67bbe9ee..ae426356 100644 --- a/frontend/src/features/start-mapping/components/map/map.tsx +++ b/frontend/src/features/start-mapping/components/map/map.tsx @@ -169,7 +169,7 @@ export const StartMappingMapComponent = ({ openAerialMap={!modelInfoRequestIsPending} oamTileJSONURL={tileJSONURL} > - {!modelInfoRequestIsPending && + {!modelInfoRequestIsPending && ( <> - } + + )} {showPopup && ( { if (!map) return; // There is a mismatch of 1 in the mag.getZoom() results and the actual zoom level of the map. // Adding 1 to the result resolves it. + setCurrentZoom(Math.round(map.getZoom()) + 1); }, [map]); From dcb9e745583185c9ab29ba9a05778d4271d037ba Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Wed, 26 Feb 2025 09:54:08 +0100 Subject: [PATCH 7/8] feat: improved model creation flow + local storage persistence --- .../src/app/providers/models-provider.tsx | 126 ++++++++++++------ frontend/src/app/router.tsx | 8 +- .../src/app/routes/models/confirmation.tsx | 19 ++- .../app/routes/models/model-details-card.tsx | 36 +---- frontend/src/config/index.ts | 6 +- .../constants/ui-contents/models-content.ts | 2 + .../components/progress-bar.tsx | 17 ++- .../components/progress-buttons.tsx | 10 ++ .../model-creation/hooks/use-models.ts | 3 - .../features/models/api/update-trainings.ts | 2 +- .../dialogs/model-enhancement-dialog.tsx | 1 + .../dialogs/training-settings-dialog.tsx | 13 +- frontend/src/layouts/model-forms-layout.tsx | 4 +- frontend/src/types/ui-contents.ts | 1 + 14 files changed, 153 insertions(+), 95 deletions(-) diff --git a/frontend/src/app/providers/models-provider.tsx b/frontend/src/app/providers/models-provider.tsx index 5d32fdf4..54ec8b20 100644 --- a/frontend/src/app/providers/models-provider.tsx +++ b/frontend/src/app/providers/models-provider.tsx @@ -5,15 +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 { 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, @@ -25,6 +26,7 @@ import { } from "@/utils"; import React, { createContext, + useCallback, useContext, useEffect, useMemo, @@ -40,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. @@ -228,6 +232,10 @@ const ModelsContext = createContext<{ handleModelCreationAndUpdate: () => void; handleTrainingDatasetCreation: () => void; validateEditMode: boolean; + isError: boolean; + isPending: boolean; + data: TModelDetails; + isModelOwner: boolean; }>({ formData: initialFormState, setFormData: () => {}, @@ -254,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<{ @@ -261,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, @@ -284,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; @@ -299,22 +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 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 and training dataset 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( @@ -336,27 +366,24 @@ export const ModelsProvider: React.FC<{ ); }, [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); @@ -384,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 = () => { + if (isModelOwner) { + showSuccessToast( + isEditMode + ? TOAST_NOTIFICATIONS.modelUpdateSuccess + : TOAST_NOTIFICATIONS.modelCreationSuccess, + ); + } + + navigate(`${getFullPath(MODELS_ROUTES.CONFIRMATION)}?id=${modelId}`); + // Submit the model for training request + submitTrainingRequest(); }; const modelCreateMutation = useCreateModel({ @@ -449,7 +483,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, @@ -457,6 +493,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, @@ -475,7 +515,6 @@ export const ModelsProvider: React.FC<{ hasLabeledTrainingAreas, hasAOIsWithGeometry, formData, - resetState, createNewTrainingRequestMutation, isEditMode, modelId, @@ -484,6 +523,11 @@ export const ModelsProvider: React.FC<{ handleTrainingDatasetCreation, trainingDatasetCreationInProgress, validateEditMode, + resetState, + data, + isPending, + isError, + isModelOwner, }), [ setFormData, @@ -492,15 +536,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 b43ceeb7..63cf24d2 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: () => ( + + {" "} + + + ), }; }, }, 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, diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts index 584efb7e..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"; /** @@ -381,7 +381,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; // ============================================================================================================================== 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/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 (