diff --git a/frontend/.env.sample b/frontend/.env.sample index 9cb470a6f..408832d25 100644 --- a/frontend/.env.sample +++ b/frontend/.env.sample @@ -6,7 +6,12 @@ VITE_BASE_API_URL = "http://localhost:8000/api/v1/" # Authentication: "legacy" or "hanko" VITE_AUTH_PROVIDER = "legacy" -# VITE_HANKO_URL = "https://dev.login.hotosm.org" + + +# The Hanko authentication URL for user authentication. +# Data type: String (e.g., "https://dev.login.hotosm.org"). +# Default value: "https://dev.login.hotosm.org". +VITE_HANKO_URL = "https://dev.login.hotosm.org" # The matomo application ID. # Data type: Positive Integer (e.g., 0). @@ -225,35 +230,35 @@ VITE_MAXIMUM_ORTHO_SKEW_TOLEARANCE_AND_MAX_ANGLE_CHANGE_IN_DEGREES = 45 # Data type: String (e.g., "#247DCACC"). # Note: Colors must be hex codes or valid colors. e.g red, green, #fff. # Default value: "#D73434". -PREDICTIONS_RESULTS_POINT_FILL_COLOR = "#34D77C" +VITE_PREDICTIONS_RESULTS_POINT_FILL_COLOR = "#34D77C" # The outline color for the prediction requests points. # Data type: String (e.g., "#247DCACC"). # Note: Colors must be hex codes or valid colors. e.g red, green, #fff. # Default value: "#D73434". -PREDICTIONS_RESULTS_POINT_OUTLINE_COLOR = "#34D77C" +VITE_PREDICTIONS_RESULTS_POINT_OUTLINE_COLOR = "#34D77C" #The URL to fetch the latest fair updates. # Data type: String # Default value: "https://raw.githubusercontent.com/hotosm/fAIr/develop/docs/assets/fair-updates.json". -FAIR_VIDEO_UPDATES_URL = "https://raw.githubusercontent.com/hotosm/fAIr/develop/docs/assets/fair-updates.json"; +VITE_FAIR_VIDEO_UPDATES_URL = "https://raw.githubusercontent.com/hotosm/fAIr/develop/docs/assets/fair-updates.json"; #FAIR Youtube Backup Video URL to use when invalid youtube url is returned. # Data type: String # Default value: "https://www.youtube.com/embed/N2_9Bvm05_0?si=to_2aoeRCW3APmmZ". -FAIR_VIDEO_BACKUP_URL = "https://www.youtube.com/embed/N2_9Bvm05_0?si=to_2aoeRCW3APmmZ" +VITE_FAIR_VIDEO_BACKUP_URL = "https://www.youtube.com/embed/N2_9Bvm05_0?si=to_2aoeRCW3APmmZ" # The number of required MapSwipe validations for a prediction to be considered valid. # Data type: Positive Integer (e.g., 4). # Default value: 4. -MAPSWIPE_VERIFICATION_NUMBER = 4 +VITE_MAPSWIPE_VERIFICATION_NUMBER = 4 # The group size for MapSwipe projects. # Data type: Positive Integer (e.g., 25). # Default value: 25. -MAPSWIPE_GROUP_SIZE = 25 +VITE_MAPSWIPE_GROUP_SIZE = 25 # The Hanko authentication token for user authentication. ####### ONLY NEEDED IN DEVELOPMENT ENVIRONMENT. ######## @@ -277,3 +282,9 @@ VITE_TRY_FAIR_GRID_SIZE = 5 # Data type: String # Default value: "https://fair.hotosm.org/". VITE_FAIR_PROD_URL = "https://fair.hotosm.org/" + +# When set to "true", bypasses authentication checks on the Try fAIr page, +# allowing access to imagery/location flows without signing in. +# Data type: Boolean string ("true" / "false") +# Default value: "false". +VITE_DISABLE_AUTH_ON_TRY_FAIR = "true" diff --git a/frontend/package.json b/frontend/package.json index 09a97bb68..805a42ecd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "prepare": "cd .. && husky frontend/.husky" }, "dependencies": { + "@hookform/resolvers": "^5.4.0", "@hotosm/hanko-auth": "^0.5.2", "@hotosm/tool-menu": "0.2.7", "@reactour/tour": "^3.7.0", @@ -41,6 +42,7 @@ "react-dom": "19.1.0", "react-dropzone": "^14.2.10", "react-error-boundary": "^4.0.13", + "react-hook-form": "^7.82.0", "react-markdown": "^10.1.0", "react-medium-image-zoom": "^5.2.11", "react-router-dom": "^6.26.2", @@ -50,6 +52,7 @@ "terra-draw-maplibre-gl-adapter": "^1.0.1", "vaul": "^1.1.2", "xmlbuilder2": "^3.1.1", + "zod": "^4.4.3", "zustand": "^5.0.3" }, "devDependencies": { diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index cdbd1606c..633dc70f9 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: .: dependencies: + '@hookform/resolvers': + specifier: ^5.4.0 + version: 5.4.0(react-hook-form@7.82.0(react@19.1.0)) '@hotosm/hanko-auth': specifier: ^0.5.2 version: 0.5.2 @@ -90,6 +93,9 @@ importers: react-error-boundary: specifier: ^4.0.13 version: 4.0.13(react@19.1.0) + react-hook-form: + specifier: ^7.82.0 + version: 7.82.0(react@19.1.0) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.1.8)(react@19.1.0) @@ -117,6 +123,9 @@ importers: xmlbuilder2: specifier: ^3.1.1 version: 3.1.1 + zod: + specifier: ^4.4.3 + version: 4.4.3 zustand: specifier: ^5.0.3 version: 5.0.3(@types/react@19.1.8)(react@19.1.0) @@ -561,6 +570,11 @@ packages: '@floating-ui/utils@0.2.8': resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + '@hookform/resolvers@5.4.0': + resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} + peerDependencies: + react-hook-form: ^7.55.0 + '@hotosm/hanko-auth@0.5.2': resolution: {integrity: sha512-dCCrNRyH2C2bS3TBDLJrX45rmHPgMzZHp8Il+LvEeh9mHzBB/iXro1bJZj5Fpmdhfy3HVN+v5PE2WhMK3ymznw==} @@ -963,6 +977,9 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@tailwindcss/typography@0.5.15': resolution: {integrity: sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==} peerDependencies: @@ -2891,6 +2908,12 @@ packages: peerDependencies: react: '>=16.13.1' + react-hook-form@7.82.0: + resolution: {integrity: sha512-Zw/uFZ2dO+02GHlBn7JFGn8kZJ7LdM33B/0BXOovzFay+CMhf94JMw5BVu+F1tVkUKjNvBuaE3fz5BJhga10Tg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -3583,6 +3606,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@5.0.3: resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==} engines: {node: '>=12.20.0'} @@ -3914,6 +3940,11 @@ snapshots: '@floating-ui/utils@0.2.8': {} + '@hookform/resolvers@5.4.0(react-hook-form@7.82.0(react@19.1.0))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.82.0(react@19.1.0) + '@hotosm/hanko-auth@0.5.2': dependencies: '@teamhanko/hanko-elements': 2.5.0 @@ -4263,6 +4294,8 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@standard-schema/utils@0.3.0': {} + '@tailwindcss/typography@0.5.15(tailwindcss@3.4.13)': dependencies: lodash.castarray: 4.4.0 @@ -6671,6 +6704,10 @@ snapshots: '@babel/runtime': 7.25.6 react: 19.1.0 + react-hook-form@7.82.0(react@19.1.0): + dependencies: + react: 19.1.0 + react-is@16.13.1: {} react-is@17.0.2: {} @@ -7497,6 +7534,8 @@ snapshots: yocto-queue@0.1.0: {} + zod@4.4.3: {} + zustand@5.0.3(@types/react@19.1.8)(react@19.1.0): optionalDependencies: '@types/react': 19.1.8 diff --git a/frontend/src/app/providers/auth-provider.tsx b/frontend/src/app/providers/auth-provider.tsx index 30c7c975e..609b83e25 100644 --- a/frontend/src/app/providers/auth-provider.tsx +++ b/frontend/src/app/providers/auth-provider.tsx @@ -4,6 +4,7 @@ import { authService } from "@/services"; import { AUTH_PROVIDER, BASE_API_URL, + DISABLE_AUTH_ON_TRY_FAIR, HOT_FAIR_LOCAL_STORAGE_ACCESS_TOKEN_KEY, HOT_FAIR_LOGIN_SUCCESSFUL_SESSION_KEY, HOT_FAIR_SESSION_REDIRECT_KEY, @@ -165,6 +166,7 @@ export const AuthProvider: React.FC = ({ children }) => { }; useEffect(() => { + if (DISABLE_AUTH_ON_TRY_FAIR) return; if (AUTH_PROVIDER === "hanko") { fetchUserProfile(); } else if (token) { @@ -234,6 +236,7 @@ export const AuthProvider: React.FC = ({ children }) => { useEffect(() => { if (AUTH_PROVIDER !== "hanko") return; + if (DISABLE_AUTH_ON_TRY_FAIR) return; const handleLogin = (e: Event) => { const user = (e as CustomEvent).detail?.user; @@ -261,6 +264,7 @@ export const AuthProvider: React.FC = ({ children }) => { * This is majorly to keep the user profile information up to date, especially when the user is logged in. */ useEffect(() => { + if (DISABLE_AUTH_ON_TRY_FAIR) return; const intervalId = setInterval(() => { if (AUTH_PROVIDER === "hanko" && !IS_DEV) { fetch(`${BASE_API_URL}auth/me/`, { credentials: "include" }) diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 042ac7e7d..c52460e42 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -1,4 +1,5 @@ import { APPLICATION_ROUTES } from "@/constants"; +import { isNavigationRouteEnabled } from "@/constants/general"; import { MainErrorFallback } from "@/components/errors"; import { ModelFormsLayout, @@ -10,13 +11,34 @@ import { Navigate, RouterProvider, createBrowserRouter, + useLocation, } from "react-router-dom"; import { ModelsProvider } from "@/app/providers/models-provider"; import { NuqsAdapter } from "nuqs/adapters/react-router/v6"; +const RouteAvailabilityGuard = ({ children }: React.PropsWithChildren) => { + const location = useLocation(); + + if (!isNavigationRouteEnabled(location.pathname)) { + return ( + + ); + } + + return children; +}; + const router = createBrowserRouter([ { - element: , + element: ( + + + + ), children: [ /** * Landing page route starts diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index 7b1517560..1f41ea33b 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -1,50 +1,76 @@ import { Head } from "@/components/seo"; import { TRY_FAIR_PAGE_CONTENT } from "@/constants/ui-contents/try-fair-contents"; -import { TryFairMapOutputType, TryFairResolution } from "@/enums/try-fair"; +import { + ImagerySource, + ModelType, + TryFairMapOutputType, + TryFairResolution, +} from "@/enums"; import { TryFairMap } from "@/features/try-fair/components/map/try-fair-map"; import { TryFairSidebar } from "@/features/try-fair/components/try-fair-sidebar"; import { ModelPickerContent } from "@/features/try-fair/components/model-picker-modal"; import { getSelectedModel } from "@/features/try-fair/utils/models"; import { useMapInstance } from "@/hooks/use-map-instance"; -import { useTileservice } from "@/hooks/use-tileservice"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { getTileServerRegex, getTileServerTypeFromURL } from "@/utils"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { showErrorToast } from "@/utils"; import { useTryFairParams } from "@/features/try-fair/hooks/use-try-fair-params"; import { - useBaseModels, - useLocalModels, + useStacBaseModels, + useStacLocalModels, } from "@/features/try-fair/hooks/use-base-models"; import { BBOX } from "@/types"; +import { MapLargeAreaModal } from "@/features/try-fair/components/start-mapping/map-large-area-modal"; import { useFairPredict } from "@/features/try-fair/hooks/use-fair-predict"; -import { - getTryFairGuidedTourSteps, - getTryFairStartMappingStep, -} from "@/constants/site-tour"; +import { useTryFairImagery } from "@/features/try-fair/hooks/use-try-fair-imagery"; +import { useTryFairTour } from "@/features/try-fair/hooks/use-try-fair-tour"; import { BaseModelStacItem, getInferenceParams, - InferenceParam, } from "@/features/try-fair/api/stac"; +import { useImageryMappingModel } from "@/features/try-fair/hooks/use-imagery-mapping-model"; import useScreenSize from "@/hooks/use-screen-size"; import { MobileDrawer } from "@/components/ui/drawer"; -import { TRY_FAIR_TOUR_START_MAPPING_BUTTON_SEEN_LOCAL_STORAGE_KEY } from "@/config"; -import { useLocalStorage } from "@/hooks/use-storage"; -import { useTour, type StepType } from "@reactour/tour"; -import { - DEFAULT_FAIR_IMAGERY_CENTER, - FALLBACK_FAIR_IMAGERY, - FALLBACK_FAIR_IMAGERY_CENTER, - getModelOutputType, - TRY_FAIR_INITIAL_MAP_ZOOM, -} from "@/features/try-fair/utils/common"; +import { getModelOutputType } from "@/features/try-fair/utils/common"; import { Dialog } from "@/components/ui/dialog"; import { useDialog } from "@/hooks/use-dialog"; +import { ImageryLocationDialog } from "@/features/try-fair/components/imagery/imagery-location-modal"; +import { useStartMappingStore } from "@/features/try-fair/utils/start-mapping-store"; +import { SignInPromptDialog } from "@/features/try-fair/components/modals/sign-in-prompt"; +import { DISABLE_AUTH_ON_TRY_FAIR } from "@/config"; +import type { ImagerySelection } from "@/features/try-fair/types/imagery-types"; +import { MapLargeAreaRequestSuccess } from "@/features/try-fair/components/start-mapping/map-large-area-request-success-dialog"; +import { useShallow } from "zustand/react/shallow"; export const TryFairPage = () => { const { map, mapContainerRef } = useMapInstance(false, false); const { isSmallViewport } = useScreenSize(); - const { getValue, setValue } = useLocalStorage(); - const { setIsOpen: setIsSiteTourOpen, setCurrentStep, setSteps } = useTour(); + const { + showChooseLocationModal, + setShowChooseLocationModal, + showSigninModal, + setShowSigninModal, + downloadType, + setDownloadType, + setPredictions: setPredictionsInStore, + setPredictionBBox: setPredictionBBoxInStore, + setPredictionGridZoom: setPredictionGridZoomInStore, + setOutputType: setOutputTypeInStore, + } = useStartMappingStore( + useShallow((state) => ({ + showChooseLocationModal: state.showChooseLocationModal, + setShowChooseLocationModal: state.setShowChooseLocationModal, + showSigninModal: state.showSigninModal, + setShowSigninModal: state.setShowSigninModal, + downloadType: state.downloadType, + setDownloadType: state.setDownloadType, + setPredictions: state.setPredictions, + setPredictionBBox: state.setPredictionBBox, + setPredictionGridZoom: state.setPredictionGridZoom, + setOutputType: state.setOutputType, + })), + ); + const { closeGuidedTour, openGuidedTour, recordMapRun } = + useTryFairTour(isSmallViewport); const { modelId, @@ -55,42 +81,81 @@ export const TryFairPage = () => { setOutputType, setResolution, setConfidence, + feature, + setFeature, + mode, + setMode, + imageryUrl, + imageryTileServiceType, + oamItemId, + setImagery, + isParametersDefault, + resetParameters, } = useTryFairParams(); - const { models: allModels, loading: modelsLoading } = useBaseModels(); - const { models: localModels, loading: localModelLoading } = useLocalModels(); + const { models: allModels, loading: modelsLoading } = useStacBaseModels(); + const { models: localModels, loading: localModelLoading } = + useStacLocalModels(); const models = useMemo( () => [...allModels, ...localModels], [allModels, localModels], ); + const selectedModel = useMemo( () => getSelectedModel(models, modelId), [models, modelId], ); - const demoConfig = useMemo( - () => - selectedModel ? getSelectedModel(models, selectedModel.id) : undefined, - [selectedModel], - ); - - const inferenceParams: InferenceParam[] = useMemo( - () => (selectedModel ? getInferenceParams(selectedModel) : []), - [selectedModel], - ); + const { + imageryBounds, + imageryCenter, + setCurrentModelType, + setSeletedImagery, + tileLoading, + tileServiceTypeValidity, + tileserverURL, + } = useTryFairImagery({ + map, + selectedModel, + mode, + imageryUrl, + imageryTileServiceType, + oamItemId, + }); - const paramValues = useMemo(() => { - const values: Record = {}; - inferenceParams.forEach(({ key, spec }) => { - values[key] = key === "confidence_threshold" ? confidence : spec.default; - }); - return values; - }, [inferenceParams, confidence]); + const { + modelForMapping, + mappingModelId, + modelUri, + hasNoModelsForFeature, + inferenceParams, + paramValues, + } = useImageryMappingModel({ + feature, + confidence, + selectedModel, + }); const [latestBBox, setLatestBBox] = useState(null); const [latestGridZoom, setLatestGridZoom] = useState(null); - const [isDirty, setIsDirty] = useState(true); + // Snapshot of the current prediction inputs vs what was last submitted, + const lastPredictedInputsRef = useRef(null); + + const predictionInputsSnapshot = useMemo(() => { + if (!latestBBox || !mappingModelId) return null; + return JSON.stringify({ + mappingModelId, + bbox: latestBBox, + gridZoom: latestGridZoom, + resolution, + paramValues, + }); + }, [mappingModelId, latestBBox, latestGridZoom, resolution, paramValues]); + + const isDirty = + predictionInputsSnapshot === null || + predictionInputsSnapshot !== lastPredictedInputsRef.current; const { openDialog: openModelPickerDialog, @@ -98,28 +163,6 @@ export const TryFairPage = () => { closeDialog: closeModelPickerDialog, } = useDialog(); - const [mapClickCount, setMapClickCount] = useState(0); - - const [tourStepStartMappingButtonSeen, setTourStepStartMappingButtonSeen] = - useState( - () => - getValue(TRY_FAIR_TOUR_START_MAPPING_BUTTON_SEEN_LOCAL_STORAGE_KEY) === - "true", - ); - - const tileServiceUrl = useMemo(() => { - const modelImagery = selectedModel?.properties["fair:source_imagery"]; - if (!modelImagery) return FALLBACK_FAIR_IMAGERY; - const regex = getTileServerRegex(getTileServerTypeFromURL(modelImagery)); - return regex.test(modelImagery) ? modelImagery : FALLBACK_FAIR_IMAGERY; - }, [selectedModel]); - const { - tileserverURL, - setTileserverURL, - loading: tileLoading, - tileJSONMetadata, - tileServiceTypeValidity, - } = useTileservice(getTileServerTypeFromURL(tileServiceUrl), tileServiceUrl); // Site tour trigger logic based on map interactions and prediction state. const GRID_ZOOM_IN_DURATION = 1500; @@ -153,60 +196,22 @@ export const TryFairPage = () => { } }, [latestBBox, map]); - useEffect(() => { - setTileserverURL(tileServiceUrl); - }, [tileServiceUrl, setTileserverURL]); - - const imageryCenter = useMemo((): [number, number] => { - const previewLocation = selectedModel?.properties["fair:preview_location"]; - if (previewLocation) { - return previewLocation.coordinates; - } - if (tileJSONMetadata?.center) { - return [tileJSONMetadata.center[0], tileJSONMetadata.center[1]]; - } - if (tileJSONMetadata?.bounds) { - const [w, s, e, n] = tileJSONMetadata.bounds as [ - number, - number, - number, - number, - ]; - return [(w + e) / 2, (s + n) / 2]; - } - return tileServiceUrl === FALLBACK_FAIR_IMAGERY - ? FALLBACK_FAIR_IMAGERY_CENTER - : DEFAULT_FAIR_IMAGERY_CENTER; - }, [tileJSONMetadata, tileServiceUrl, selectedModel]); - - useEffect(() => { - if (!map || !selectedModel || !imageryCenter) return; - const doFly = () => { - map.flyTo({ - center: imageryCenter, - zoom: TRY_FAIR_INITIAL_MAP_ZOOM, - essential: true, - }); - }; - if (map.isStyleLoaded()) { - doFly(); - } else { - map.once("load", doFly); - return () => { - map.off("load", doFly); - }; - } - }, [map, selectedModel, imageryCenter]); - + // Map Large Area (Export → Map Large Area). Opens when downloadType is set to + const [isLargeAreaSuccessDialogOpen, setIsLargeAreaSuccessDialogOpen] = + useState(false); + const handleLargeAreaSubmit = () => { + setDownloadType(""); + setIsLargeAreaSuccessDialogOpen(true); + }; useEffect(() => { if ( - selectedModel && - getModelOutputType(selectedModel) === TryFairMapOutputType.POINTS && + modelForMapping && + getModelOutputType(modelForMapping) === TryFairMapOutputType.POINTS && outputType === TryFairMapOutputType.POLYGON ) { setOutputType(TryFairMapOutputType.POINTS); } - }, [selectedModel, outputType, setOutputType]); + }, [modelForMapping, outputType, setOutputType]); const { predict, @@ -215,10 +220,14 @@ export const TryFairPage = () => { predictionBBox, predictionGridZoom, clearPredictions, + error, } = useFairPredict(); const handleSelectModel = (model: BaseModelStacItem) => { setModelId(model.id); + setCurrentModelType(ModelType.DEMO); + setMode(ModelType.DEMO); + setImagery({ url: null, tileServiceType: null, oamItemId: null }); setResolution(TryFairResolution.MID); // Reset confidence threshold to model's spec default if available @@ -230,19 +239,18 @@ export const TryFairPage = () => { setConfidence(confidenceParam.spec.default); } setOutputType(getModelOutputType(model)); - setIsDirty(true); + // Invalidate so the Map button re-enables for the new model + lastPredictedInputsRef.current = null; clearPredictions(); }; const handleResolutionChange = (res: TryFairResolution) => { setResolution(res); - setIsDirty(true); }; const handleParamChange = useCallback( (key: string, value: number | string | boolean) => { if (key === "confidence_threshold") setConfidence(value as number); - setIsDirty(true); }, [setConfidence], ); @@ -250,49 +258,25 @@ export const TryFairPage = () => { const handleBBoxChange = useCallback((bbox: BBOX, tileZoom: number) => { setLatestBBox(bbox); setLatestGridZoom(tileZoom); - setIsDirty(true); }, []); - const guidedTourSteps = useMemo( - () => getTryFairGuidedTourSteps(isSmallViewport), - [isSmallViewport], - ); - - const startMappingStep = useMemo( - () => getTryFairStartMappingStep(), - [], - ); - - const openGuidedTour = useCallback(() => { - const firstSelector = guidedTourSteps[0]?.selector; + const handleMap = useCallback(() => { if ( - typeof firstSelector === "string" && - !document.querySelector(firstSelector) + hasNoModelsForFeature || + !modelForMapping || + !mappingModelId || + !modelUri || + !latestBBox ) return; - setSteps?.(guidedTourSteps); - setCurrentStep(0); - setIsSiteTourOpen(true); - }, [guidedTourSteps, setCurrentStep, setIsSiteTourOpen, setSteps]); - - const openStartMappingStep = useCallback(() => { - const selector = startMappingStep.selector; - if (typeof selector === "string" && !document.querySelector(selector)) - return; - setSteps?.([startMappingStep]); - setCurrentStep(0); - setIsSiteTourOpen(true); - }, [startMappingStep, setCurrentStep, setIsSiteTourOpen, setSteps]); - - const handleMap = useCallback(() => { - if (!selectedModel || !latestBBox || !demoConfig) return; - setIsSiteTourOpen(false); + closeGuidedTour(); // Always centerlize the grid whenever the user clicks on Map // This is to prevent situations whereby the user drags the grid to another place and the prediction is not visible to them. handleZoomToGrid(); - setIsDirty(false); - setMapClickCount((n) => n + 1); + // Save what we just submitted so we can detect duplicate runs + lastPredictedInputsRef.current = predictionInputsSnapshot; + recordMapRun(); const apiParams = Object.fromEntries( Object.entries(paramValues).map(([parameterName, parameterValue]) => parameterName === "confidence_threshold" @@ -300,75 +284,140 @@ export const TryFairPage = () => { : [parameterName, parameterValue], ), ); + predict({ - model: selectedModel, - localModelUri: selectedModel?.assets?.model?.href ?? "", - tileServiceUrl: tileserverURL, + model: modelForMapping, + modelUri, + imageUri: tileserverURL, bbox: latestBBox, gridZoom: latestGridZoom ?? undefined, resolution, params: apiParams, }); + if (error) { + console.log(error); + showErrorToast(error); + } }, [ - selectedModel, + modelForMapping, + mappingModelId, + modelUri, latestBBox, - demoConfig, paramValues, predict, tileserverURL, latestGridZoom, resolution, + predictionInputsSnapshot, + closeGuidedTour, + recordMapRun, + hasNoModelsForFeature, ]); - - const isMapButtonDisabled = !isDirty || !latestBBox || !demoConfig; - useEffect(() => { - if ( - mapClickCount === 4 && - !isSmallViewport && - !tourStepStartMappingButtonSeen - ) { - openStartMappingStep(); - setValue( - TRY_FAIR_TOUR_START_MAPPING_BUTTON_SEEN_LOCAL_STORAGE_KEY, - "true", - ); - setTourStepStartMappingButtonSeen(true); + if (error) { + showErrorToast(error); } - }, [ - mapClickCount, - isSmallViewport, - tourStepStartMappingButtonSeen, - openStartMappingStep, - setValue, - ]); + }, [error]); + const isMapButtonDisabled = + hasNoModelsForFeature || + !isDirty || + !latestBBox || + !modelForMapping || + !mappingModelId || + !modelUri; const handleOutputTypeChange = (type: TryFairMapOutputType) => { setOutputType(type); + setOutputTypeInStore(type); if (isDirty && predictions) { handleMap(); } }; + const handleApplyImagery = useCallback( + (selection: ImagerySelection) => { + setCurrentModelType(ModelType.IMAGERY); + setSeletedImagery(selection); + setMode(ModelType.IMAGERY); + setImagery({ + url: + selection.source === ImagerySource.CUSTOM ? selection.tileUrl : null, + tileServiceType: + selection.source === ImagerySource.CUSTOM + ? selection.tileServiceType + : null, + oamItemId: + selection.source === ImagerySource.OPEN_AERIAL_MAP + ? selection.item.id + : null, + }); + // Invalidate the last prediction so the Map button re-enables and stale + // predictions clear when the imagery changes. + lastPredictedInputsRef.current = null; + clearPredictions(); + setShowChooseLocationModal(false); + }, + [ + clearPredictions, + setCurrentModelType, + setImagery, + setMode, + setSeletedImagery, + setShowChooseLocationModal, + ], + ); + // Sync prediction state into the global store so the navbar Export button can access it. + useEffect(() => { + setPredictionsInStore(predictions); + setPredictionBBoxInStore(predictionBBox); + setPredictionGridZoomInStore(predictionGridZoom); + }, [predictions, predictionBBox, predictionGridZoom]); return ( <> {/* Model picker dialog – rendered at page level so it's not trapped inside MobileDrawer */} { - handleSelectModel(model); - closeModelPickerDialog(); - }} + selectedModel={modelForMapping} + onSelect={handleSelectModel} models={models} + onClose={closeModelPickerDialog} /> + {/* Imagery/location dialog – rendered at page level */} + setShowChooseLocationModal(false)} + onApply={handleApplyImagery} + /> + {!DISABLE_AUTH_ON_TRY_FAIR && ( + setShowSigninModal(false)} + /> + )} + + {/* Map Large Area (Export → Map Large Area) */} + setDownloadType("")} + tileServerURL={tileserverURL} + imageryBounds={imageryBounds} + onSubmit={handleLargeAreaSubmit} + /> + + setIsLargeAreaSuccessDialogOpen(false)} + /> + + {/* Signin Prompt */} +
{ predictionGridZoom={predictionGridZoom} imageryCenter={imageryCenter} resolution={resolution} - modelId={modelId} + modelId={mappingModelId ?? modelId} isPredicting={isPredicting} canFitToBounds={true} onHelp={openGuidedTour} @@ -393,7 +442,7 @@ export const TryFairPage = () => { {!isSmallViewport && (
{ inferenceParams={inferenceParams} paramValues={paramValues} onParamChange={handleParamChange} + onResetParameters={resetParameters} + isParametersDefault={isParametersDefault} onMap={handleMap} isPredicting={isPredicting} isMapButtonDisabled={isMapButtonDisabled} + feature={feature} + onFeatureChange={setFeature} />
)} @@ -422,7 +475,7 @@ export const TryFairPage = () => { handleOnly > { inferenceParams={inferenceParams} paramValues={paramValues} onParamChange={handleParamChange} + onResetParameters={resetParameters} + isParametersDefault={isParametersDefault} onMap={handleMap} isPredicting={isPredicting} isMapButtonDisabled={isMapButtonDisabled} + feature={feature} + onFeatureChange={setFeature} className="w-full shadow-none" openMobileModelPickerDialog={openModelPickerDialog} /> diff --git a/frontend/src/components/landing/header/header.tsx b/frontend/src/components/landing/header/header.tsx index 729c85021..d564c8cd7 100644 --- a/frontend/src/components/landing/header/header.tsx +++ b/frontend/src/components/landing/header/header.tsx @@ -4,7 +4,6 @@ import { APPLICATION_ROUTES, SHARED_CONTENT } from "@/constants"; import { Button } from "@/components/ui/button"; import { Image } from "@/components/ui/image"; import { Link } from "@/components/ui/link"; -import { ButtonVariant } from "@/enums"; export const Header = () => { return ( @@ -22,10 +21,10 @@ export const Header = () => { nativeAnchor={false} > - { - + */}
diff --git a/frontend/src/components/layouts/navbar/nav-logo.tsx b/frontend/src/components/layouts/navbar/nav-logo.tsx index a26a11845..aa533e625 100644 --- a/frontend/src/components/layouts/navbar/nav-logo.tsx +++ b/frontend/src/components/layouts/navbar/nav-logo.tsx @@ -1,28 +1,17 @@ import { APPLICATION_ROUTES, SHARED_CONTENT } from "@/constants"; import { HOTLogo } from "@/assets/svgs"; import { Image } from "@/components/ui/image"; -import { useLocation, useNavigate } from "react-router-dom"; -import { FAIR_PROD_URL } from "@/config"; +import { useNavigate } from "react-router-dom"; export const NavLogo = ({ - onClick, smallerSize, }: { onClick?: () => void; smallerSize?: boolean; }) => { - const { pathname } = useLocation(); - const isTryFairPage = pathname.includes(APPLICATION_ROUTES.TRY_FAIR); - const navigate = useNavigate(); const handleClick = () => { - if (onClick) { - onClick(); - } else if (isTryFairPage) { - window.location.href = FAIR_PROD_URL; - } else { - navigate(APPLICATION_ROUTES.HOMEPAGE); - } + navigate(APPLICATION_ROUTES.HOMEPAGE); }; return ( diff --git a/frontend/src/components/layouts/navbar/navbar.tsx b/frontend/src/components/layouts/navbar/navbar.tsx index e6fc3ed66..96bc22650 100644 --- a/frontend/src/components/layouts/navbar/navbar.tsx +++ b/frontend/src/components/layouts/navbar/navbar.tsx @@ -18,14 +18,18 @@ import { DropDown } from "@/components/ui/dropdown"; import { AUTH_PROVIDER, BASE_API_URL, + DISABLE_AUTH_ON_TRY_FAIR, FRONTEND_URL, HANKO_URL, IS_DEV, - FAIR_PROD_URL, } from "@/config"; import "@hotosm/tool-menu"; import { Divider } from "@/components/ui/divider"; import { ToolTip } from "@/components/ui/tooltip"; +import ExportMapResults from "@/features/try-fair/components/start-mapping/export-map-results"; +import MappingMode from "@/features/try-fair/components/mapping-mode"; +import { ShareProjectModal } from "@/features/try-fair/components/modals/share-project-modal"; +import { StartMappingNavlinks } from "@/features/try-fair/components/try-fair-nav-links"; if (AUTH_PROVIDER === "hanko") { import("@hotosm/hanko-auth"); @@ -48,12 +52,14 @@ const HankoAuthComponent = ({ displayBar }: { displayBar?: boolean }) => ( export const NavBar = () => { const [open, setOpen] = useState(false); - const { isAuthenticated } = useAuth(); + const { isAuthenticated: _isAuthenticated } = useAuth(); const navigate = useNavigate(); const location = useLocation(); const isTryFairPage = location.pathname.includes(APPLICATION_ROUTES.TRY_FAIR); + const isAuthenticated = + (isTryFairPage && DISABLE_AUTH_ON_TRY_FAIR) || _isAuthenticated; const isHankoAuth = AUTH_PROVIDER === "hanko"; return ( <> @@ -81,12 +87,12 @@ export const NavBar = () => { />
)} - {isAuthenticated && } + {_isAuthenticated && }
{isHankoAuth && !IS_DEV && !isTryFairPage ? ( <> - {isAuthenticated && ( + {_isAuthenticated && ( { - ) : isAuthenticated ? ( + ) : _isAuthenticated ? ( { : ButtonVariant.PRIMARY } onClick={() => { - if (isTryFairPage) { - window.location.href = FAIR_PROD_URL; - } else { - /* - * Set the `backgroundLocation` in location state so that when we open the authentication modal we still see the current page in the background. - */ - navigate(location, { - state: { backgroundLocation: location }, - }); - } + /* + * Set the `backgroundLocation` in location state so that when we open the authentication modal we still see the current page in the background. + */ + navigate(location, { + state: { backgroundLocation: location }, + }); }} > {isTryFairPage @@ -157,23 +159,31 @@ export const NavBar = () => { + ); }; diff --git a/frontend/src/components/map/setups/setup-terra-draw.ts b/frontend/src/components/map/setups/setup-terra-draw.ts index 5d39414a9..d5fb76474 100644 --- a/frontend/src/components/map/setups/setup-terra-draw.ts +++ b/frontend/src/components/map/setups/setup-terra-draw.ts @@ -14,23 +14,53 @@ import { TRAINING_AREAS_AOI_OUTLINE_COLOR, TRAINING_AREAS_AOI_OUTLINE_WIDTH, } from "@/config"; +import { BBOX } from "@/types"; + +export type TerraDrawStyleVariant = "default" | "red"; + +export const DEFAULT_AOI_STYLE = { + fillColor: TRAINING_AREAS_AOI_FILL_COLOR as TerraDrawExtend.HexColorStyling, + fillOpacity: TRAINING_AREAS_AOI_FILL_OPACITY, + outlineColor: + TRAINING_AREAS_AOI_OUTLINE_COLOR as TerraDrawExtend.HexColorStyling, + outlineWidth: TRAINING_AREAS_AOI_OUTLINE_WIDTH, +}; + +export const RED_AOI_STYLE = { + fillColor: "#D73434" as TerraDrawExtend.HexColorStyling, + fillOpacity: 0.25, + outlineColor: "#D73434" as TerraDrawExtend.HexColorStyling, + outlineWidth: 2, +}; + +/** + * Checks if every committed vertex in the ring lies inside the imagery bounds. + * The closing duplicate coordinate is excluded. + */ +const allCoordsWithinBounds = (coords: number[][], bounds: BBOX): boolean => { + const [west, south, east, north] = bounds; + // Exclude the closing duplicate (last coord === first coord) + const ring = coords.slice(0, -1); + return ring.every( + ([lng, lat]) => lng >= west && lng <= east && lat >= south && lat <= north, + ); +}; + +export const setupTerraDraw = ( + map: maplibregl.Map, + styleVariant: TerraDrawStyleVariant = "red", + /** Optional imagery bounding box – when provided, polygon vertices outside + * this box are rejected live as the user draws. */ + imageryBounds?: BBOX | null, +) => { + const styles = styleVariant === "red" ? RED_AOI_STYLE : DEFAULT_AOI_STYLE; -export const setupTerraDraw = (map: maplibregl.Map) => { return new TerraDraw({ tracked: true, adapter: new TerraDrawMapLibreGLAdapter({ map, coordinatePrecision: 20, }), - // idStrategy: { - // isValidId: () => true, - // getId: (function () { - // let id = 0; - // return function () { - // return ++id; - // }; - // })(), - // }, modes: [ new TerraDrawSelectMode({ flags: { @@ -59,11 +89,23 @@ export const setupTerraDraw = (map: maplibregl.Map) => { }), new TerraDrawPolygonMode({ validation: (feature, { updateType }) => { + if (updateType === "commit" && imageryBounds) { + // Reject any vertex committed outside the imagery bounds. + const coords = (feature.geometry as { coordinates: number[][][] }) + ?.coordinates?.[0]; + if (coords && !allCoordsWithinBounds(coords, imageryBounds)) { + return { + valid: false, + reason: "Point is outside the imagery bounds.", + }; + } + } if (updateType === "finish" || updateType === "commit") { return ValidateNotSelfIntersecting(feature); } return { valid: true }; }, + styles, }), new TerraDrawRectangleMode({ validation: (feature, { updateType }) => { @@ -74,21 +116,7 @@ export const setupTerraDraw = (map: maplibregl.Map) => { valid: true, }; }, - styles: { - // Fill colour (a string containing a 6 digit Hex color) - fillColor: - TRAINING_AREAS_AOI_FILL_COLOR as TerraDrawExtend.HexColorStyling, - - // Fill opacity (0 - 1) - fillOpacity: TRAINING_AREAS_AOI_FILL_OPACITY, - - // Outline colour (Hex color) - outlineColor: - TRAINING_AREAS_AOI_OUTLINE_COLOR as TerraDrawExtend.HexColorStyling, - - //Outline width (Integer) - outlineWidth: TRAINING_AREAS_AOI_OUTLINE_WIDTH, - }, + styles, }), ], }); diff --git a/frontend/src/components/shared/form/xyz-tile-server-input.tsx b/frontend/src/components/shared/form/xyz-tile-server-input.tsx index 1d92e294f..8f19f155b 100644 --- a/frontend/src/components/shared/form/xyz-tile-server-input.tsx +++ b/frontend/src/components/shared/form/xyz-tile-server-input.tsx @@ -1,5 +1,7 @@ import { Alert } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; import { HelpText, Input, Select } from "@/components/ui/form"; +import { ToolTip } from "@/components/ui/tooltip"; import { INPUT_TYPES, SHOELACE_SELECT_SIZES, @@ -40,6 +42,10 @@ export const XYZTileServerInput = ({ size, tileServiceType, setTileServiceType, + variant = "horizontal", + showButton, + buttonOnclick, + useAlert = true, }: { tileServerURL: string; setTileServerURL: (url: string) => void; @@ -47,16 +53,32 @@ export const XYZTileServerInput = ({ labelWithTooltip?: boolean; showBorder?: boolean; size?: SHOELACE_SIZES; + variant?: "horizontal" | "vertical"; validationStateUpdateCallback?: (validationState: { valid: boolean; message: string; }) => void; pattern?: string; tileServiceType: TileServiceType; + buttonOnclick?: () => void; + showButton?: boolean; + useAlert?: boolean; setTileServiceType: (tileServiceType: TileServiceType) => void; }) => { + const isApplyDisabled = + !tileServerURL || + !tileServerURL.trim() || + !isValid.valid || + !tileServiceType; + return ( -
+
setTileServerURL(e.target.value)} - type={INPUT_TYPES.URL} - validationStateUpdateCallback={validationStateUpdateCallback} - isValid={tileServerURL.length > 0 && isValid.valid} - size={size} - /> - {tileServerURL.length > 0 && !isValid.valid && ( - - {isValid.message} - - )} - +
+ setTileServerURL(e.target.value)} + type={INPUT_TYPES.URL} + validationStateUpdateCallback={validationStateUpdateCallback} + isValid={tileServerURL.length > 0 && isValid.valid} + size={size} + /> + {tileServerURL.length > 0 && !isValid.valid && ( + + {isValid.message} + + )} +
+ + {useAlert ? ( + + + Ensure your imagery URL has CORS enabled and adheres to the{" "} + + license requirements + + . + + + ) : ( - Ensure your imagery URL has CORS enabled and adheres to the{" "} + Ensure your imagery URL has CORS enabled and
adheres to the{" "} .
-
+ )} + + {showButton && ( +
+ + + + + +
+ )}
); }; diff --git a/frontend/src/components/shared/modals/confirmation-modal.tsx b/frontend/src/components/shared/modals/confirmation-modal.tsx index 9d277ee6f..7afb2c137 100644 --- a/frontend/src/components/shared/modals/confirmation-modal.tsx +++ b/frontend/src/components/shared/modals/confirmation-modal.tsx @@ -12,6 +12,7 @@ type ConfirmationModalProps = { isConfirming?: boolean; confirmLabel?: string; cancelLabel?: string; + rounded?: boolean; }; export const ConfirmationModal = ({ @@ -23,6 +24,7 @@ export const ConfirmationModal = ({ isConfirming = false, confirmLabel = "Confirm", cancelLabel = "Cancel", + rounded = false, }: ConfirmationModalProps) => { const { isMobile } = useScreenSize(); @@ -41,13 +43,14 @@ export const ConfirmationModal = ({

{message}

-
diff --git a/frontend/src/components/ui/banner/banner.tsx b/frontend/src/components/ui/banner/banner.tsx index 77a12b7cc..6a6fe0bf8 100644 --- a/frontend/src/components/ui/banner/banner.tsx +++ b/frontend/src/components/ui/banner/banner.tsx @@ -12,7 +12,7 @@ type TBannerResponse = { const fetchBanner = async (): Promise => { const { data } = await apiClient.get(API_ENDPOINTS.GET_BANNER); - return data; + return data.results; }; const Banner = () => { diff --git a/frontend/src/components/ui/copy-button.tsx b/frontend/src/components/ui/copy-button.tsx index 1f67101b7..981059f55 100644 --- a/frontend/src/components/ui/copy-button.tsx +++ b/frontend/src/components/ui/copy-button.tsx @@ -1,6 +1,8 @@ import useCopyToClipboard from "@/hooks/use-clipboard"; import { CheckIcon, ClipboardIcon } from "@/components/ui/icons"; import { ToolTip } from "./tooltip"; +import { IconProps } from "@/types"; +import React from "react"; export const CopyButton = ({ text, @@ -8,30 +10,34 @@ export const CopyButton = ({ label, tooltipContent = "Copy to clipboard", iconClassName = "size-6", + icon: CustomIcon, }: { text: string; size?: "small" | "large"; tooltipContent?: string; iconClassName?: string; label?: string; + icon?: React.FC; }) => { const { copyToClipboard, isCopied } = useCopyToClipboard(); const iconSize = size === "small" ? "icon" : "icon md:icon-lg"; + const DefaultIcon = CustomIcon || ClipboardIcon; return (
diff --git a/frontend/src/features/try-fair/api/features-to-map.ts b/frontend/src/features/try-fair/api/features-to-map.ts new file mode 100644 index 000000000..2d5415164 --- /dev/null +++ b/frontend/src/features/try-fair/api/features-to-map.ts @@ -0,0 +1,94 @@ +import { API_ENDPOINTS, apiClient } from "@/services"; +import { useQuery } from "@tanstack/react-query"; +import { BaseModelStacItem } from "./stac"; +export type APIBaseModelUser = { + osm_id: number; + username: string; +}; + +export type APIBaseModelItem = { + id: number; + name: string; + category: string; + stac_item_id: string; + status: string; + visibility: string; + is_pinned: boolean; + star_count: number; + is_starred: boolean; + error: string; + user: APIBaseModelUser; + created_at: string; + last_modified: string; + stac: BaseModelStacItem; +}; + +export type APIBaseModelsResponse = { + count: number; + next: string | null; + previous: string | null; + results: APIBaseModelItem[]; +}; +export type FeatureToMapItem = { + slug: string; + id: number; + description: string; + last_modified: string; + created_at: string; + label: string; +}; + +export type FeaturesToMapResponse = { + results: FeatureToMapItem[]; + count: number; + next: string | null; + previous: string | null; +}; + +const getFeaturesToMap = async (): Promise => { + const res = await apiClient.get( + API_ENDPOINTS.GET_CATEGORIES, + ); + return res.data; +}; + +export const useGetFeaturesToMap = () => { + return useQuery({ + queryKey: ["features-to-map"], + queryFn: getFeaturesToMap, + }); +}; + +export const getAPIBaseModels = async ( + category: string, +): Promise => { + const res = await apiClient.get( + API_ENDPOINTS.GET_API_BASE_MODELS(category), + ); + return res.data; +}; + +export const useGetAPIBaseModels = (category: string, enabled = true) => { + return useQuery({ + queryKey: ["api-base-models", category], + queryFn: () => getAPIBaseModels(category), + enabled: enabled && Boolean(category), + }); +}; + +export const getAPILocalModels = async ( + category: string, +): Promise => { + const res = await apiClient.get( + API_ENDPOINTS.GET_API_LOCAL_MODELS(category), + ); + return res.data; +}; + +export const useGetAPILocalModels = (category: string, enabled = true) => { + return useQuery({ + queryKey: ["api-local-models", category], + queryFn: () => getAPILocalModels(category), + enabled: enabled && Boolean(category), + }); +}; diff --git a/frontend/src/features/try-fair/api/hot-imagery.ts b/frontend/src/features/try-fair/api/hot-imagery.ts new file mode 100644 index 000000000..6416a31be --- /dev/null +++ b/frontend/src/features/try-fair/api/hot-imagery.ts @@ -0,0 +1,205 @@ +import axios from "axios"; +import { BBOX } from "@/types"; +import { + HOT_IMAGERY_COLLECTION_ID, + HOT_IMAGERY_RASTER_API_URL, + HOT_IMAGERY_STAC_API_URL, + NOMINATIM_API_URL, +} from "@/config"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** A lean, UI-friendly view of an OpenAerialMap imagery item. */ +export type OAMImageryItem = { + id: string; + bbox: BBOX; + geometry: GeoJSON.Geometry; + title: string; + provider: string; + /** Ground sample distance in meters, e.g. 0.02 for 2 cm. */ + gsd: number | null; + /** Acquisition date (ISO string). */ + acquiredAt: string | null; + license: string | null; + platform: string | null; + thumbnailUrl: string | null; + /** Name of the renderable asset on the raster API (usually "visual"). */ + assetName: string; +}; + +export type GeocodeResult = { + displayName: string; + bbox: BBOX; + center: [number, number]; +}; + +type StacImageryFeature = { + id: string; + bbox: BBOX; + type: "Feature"; + geometry: GeoJSON.Geometry; + assets: Record; + properties: { + title?: string; + gsd?: number; + license?: string; + created?: string; + start_datetime?: string; + end_datetime?: string; + providers?: Array<{ name: string }>; + "oam:platform_type"?: string; + "oam:producer_name"?: string; + }; +}; + +type StacSearchResponse = { + type: "FeatureCollection"; + features: StacImageryFeature[]; +}; + +const toImageryItem = (feature: StacImageryFeature): OAMImageryItem => { + const { properties, assets } = feature; + const assetName = assets.visual + ? "visual" + : (Object.keys(assets)[0] ?? "visual"); + return { + id: feature.id, + bbox: feature.bbox, + geometry: feature.geometry, + title: properties.title ?? "Untitled Image", + provider: + properties["oam:producer_name"] ?? + properties.providers?.[0]?.name ?? + "Unknown", + gsd: properties.gsd ?? null, + acquiredAt: properties.end_datetime ?? properties.created ?? null, + license: properties.license ?? null, + platform: properties["oam:platform_type"] ?? null, + thumbnailUrl: assets.thumbnail?.href ?? null, + assetName, + }; +}; + +// ── URL builders ────────────────────────────────────────────────────────────── + +/** + * XYZ tile URL template that renders an OpenAerialMap item through the raster + * (titiler) API — the same scheme imagery.hotosm.org uses. The item id and + * asset name come straight from the footprint PMTiles feature. + */ +export const getImageryTileUrl = ( + itemId: string, + assetName: string = "visual", +): string => + `${HOT_IMAGERY_RASTER_API_URL}/collections/${HOT_IMAGERY_COLLECTION_ID}/items/${itemId}/tiles/WebMercatorQuad/{z}/{x}/{y}@1x?assets=${assetName}&nodata=0`; + +// ── API calls ───────────────────────────────────────────────────────────────── + +/** + * List the OpenAerialMap images intersecting a bounding box, via the HOT + * imagery STAC catalog. Used to populate the "images within selected grid + * square" panel once the user picks a density cell. + */ +export const searchImagery = async ({ + bbox, + limit = 50, + signal, +}: { + bbox: BBOX; + limit?: number; + signal?: AbortSignal; +}): Promise => { + const { data } = await axios.post( + `${HOT_IMAGERY_STAC_API_URL}/search`, + { collections: [HOT_IMAGERY_COLLECTION_ID], bbox, limit }, + { signal }, + ); + return data.features.map(toImageryItem); +}; + +type NominatimResult = { + display_name: string; + boundingbox: string[]; + lat: string; + lon: string; +}; + +const toGeocodeResult = (r: NominatimResult): GeocodeResult => { + const [south, north, west, east] = r.boundingbox.map(parseFloat); + return { + displayName: r.display_name, + bbox: [west, south, east, north], + center: [parseFloat(r.lon), parseFloat(r.lat)], + }; +}; + +export type CountryResult = { + /** Best locality name, e.g. "Angra dos Reis"; falls back to country. */ + place: string; + /** Country name, e.g. "Brazil". */ + country: string; + /** ISO 3166-1 alpha-2 code, lower-cased (e.g. "br"); "" when unknown. */ + countryCode: string; +}; + +type NominatimAddress = { + city?: string; + town?: string; + village?: string; + municipality?: string; + county?: string; + state?: string; + country?: string; + country_code?: string; +}; + +/** + * Reverse-geocode a coordinate via Nominatim to a place + country. The imagery's + * tile URL carries no location, so this is derived from the imagery's center (or + * any point inside its bounds). `zoom: 10` resolves a town/city while still + * returning the country. + */ +export const reverseGeocodeCountry = async ( + lon: number, + lat: number, + signal?: AbortSignal, +): Promise => { + const { data } = await axios.get<{ address?: NominatimAddress }>( + `${NOMINATIM_API_URL}/reverse`, + { + params: { format: "json", lat, lon, zoom: 10, addressdetails: 1 }, + signal, + }, + ); + const a = data?.address; + if (!a?.country) return null; + const place = + a.city || + a.town || + a.village || + a.municipality || + a.county || + a.state || + a.country; + return { + place, + country: a.country, + countryCode: (a.country_code ?? "").toLowerCase(), + }; +}; + +/** + * Autocomplete suggestions for a location query (up to `limit`), each with a + * bounding box for map.fitBounds. Powers the search box's suggestion dropdown. + */ +export const geocodeSuggestions = async ( + query: string, + limit = 5, + signal?: AbortSignal, +): Promise => { + const { data } = await axios.get( + `${NOMINATIM_API_URL}/search`, + { params: { format: "json", q: query, limit }, signal }, + ); + return (data ?? []).map(toGeocodeResult); +}; diff --git a/frontend/src/features/try-fair/api/map-large-area.ts b/frontend/src/features/try-fair/api/map-large-area.ts new file mode 100644 index 000000000..b6e0daea0 --- /dev/null +++ b/frontend/src/features/try-fair/api/map-large-area.ts @@ -0,0 +1,35 @@ +import { API_ENDPOINTS, apiClient } from "@/services"; +import { BBOX } from "@/types"; +import { useMutation } from "@tanstack/react-query"; + +export type MapLargeAreaParams = { + confidence_threshold?: number; + [key: string]: unknown; +}; + +export type MapLargeAreaRequest = { + model_stac_id: string; + image_uri: string; + /** Whole-imagery requests use the imagery extent. */ + bbox?: BBOX; + /** Drawn/uploaded AOIs preserve their polygon in the request. */ + geom?: GeoJSON.Geometry; + zoom: number; + params?: MapLargeAreaParams; + description?: string; +}; + +const submitMapLargeArea = async (payload: MapLargeAreaRequest) => { + const res = await apiClient.post( + API_ENDPOINTS.MAP_LARGE_AREA_REQUEST, + payload, + ); + return res.data; +}; + +export const useSubmitMapLargeArea = () => { + return useMutation({ + mutationFn: (payload: MapLargeAreaRequest) => submitMapLargeArea(payload), + mutationKey: ["map-large-area"], + }); +}; diff --git a/frontend/src/features/try-fair/components/dropdowns/feature-to-map-dropdown.tsx b/frontend/src/features/try-fair/components/dropdowns/feature-to-map-dropdown.tsx new file mode 100644 index 000000000..5c146f093 --- /dev/null +++ b/frontend/src/features/try-fair/components/dropdowns/feature-to-map-dropdown.tsx @@ -0,0 +1,154 @@ +import { DropDown } from "@/components/ui/dropdown"; +import { ChevronDownIcon } from "@/components/ui/icons"; +import { BuildingIcon } from "@/components/ui/icons/buildings-icon"; +import { FeatureCheckIcon } from "@/components/ui/icons/feature-check-icon"; +import { SolarPanelIcon } from "@/components/ui/icons/solar-panel-icon"; +import { TreesIcon } from "@/components/ui/icons/trees-icon"; +import { + FeatureToMapItem, + useGetAPIBaseModels, + useGetAPILocalModels, + useGetFeaturesToMap, +} from "@/features/try-fair/api/features-to-map"; +import { useDropdownMenu } from "@/hooks/use-dropdown-menu"; +import { IconProps } from "@/types"; +import { cn } from "@/utils"; +import { useState } from "react"; + +type FeatureToMapDropdownProps = { + disabled?: boolean; + value: string; + onChange: (value: string) => void; +}; + +const FEATURE_ICONS: Record> = { + buildings: BuildingIcon, + "solar-panels": SolarPanelIcon, + "solar-panel": SolarPanelIcon, + trees: TreesIcon, +}; + +const getFeatureIcon = (value: string): React.FC => { + return FEATURE_ICONS[value] || BuildingIcon; +}; + +type FeatureOptionProps = { + disabled: boolean; + feature: FeatureToMapItem; + isSelected: boolean; + onSelect: (value: string) => void; +}; + +const FeatureOption = ({ + disabled, + feature, + isSelected, + onSelect, +}: FeatureOptionProps) => { + const { data: baseModels, isPending: isBaseModelsPending } = + useGetAPIBaseModels(feature.slug); + const { data: localModels, isPending: isLocalModelsPending } = + useGetAPILocalModels(feature.slug); + const hasNoModels = + baseModels?.results.length === 0 && localModels?.results.length === 0; + const isDisabled = + disabled || isBaseModelsPending || isLocalModelsPending || hasNoModels; + const FeatureIcon = getFeatureIcon(feature.slug); + + return ( + + ); +}; + +const FeatureToMapDropdown = ({ + disabled = false, + value, + onChange, +}: FeatureToMapDropdownProps) => { + const { data: features, isLoading } = useGetFeaturesToMap(); + const { onDropdownHide, dropdownRef } = useDropdownMenu(); + const [isOpen, setIsOpen] = useState(false); + const featureList = (features?.results ?? []).filter( + (feature) => feature.slug !== "other", + ); + + const selectedFeature = featureList.find( + (feature) => feature.slug === value, + ) ?? + featureList[0] ?? { slug: "buildings", label: "Buildings" }; + + const SelectedIcon = getFeatureIcon(selectedFeature.slug); + + const trigger = ( +
+
+ +

{selectedFeature.label}

+
+ +
+ ); + + return ( +
+

Feature to map

+ setIsOpen(true)} + onDropdownHide={() => { + setIsOpen(false); + onDropdownHide(); + }} + disableCheveronIcon + triggerComponent={trigger} + > +
+ {featureList.map((feature) => { + return ( + { + onChange(slug); + onDropdownHide(); + }} + /> + ); + })} +
+
+
+ ); +}; + +export default FeatureToMapDropdown; diff --git a/frontend/src/features/try-fair/components/imagery/choose-imagery-source.tsx b/frontend/src/features/try-fair/components/imagery/choose-imagery-source.tsx new file mode 100644 index 000000000..1f169c83d --- /dev/null +++ b/frontend/src/features/try-fair/components/imagery/choose-imagery-source.tsx @@ -0,0 +1,50 @@ +import { ImagerySource } from "@/enums"; +import { cn } from "@/utils"; +import { IMAGERY_SOURCES } from "@/features/try-fair/utils/common"; + +/** + * The OpenAerialMap / Custom Imagery pill radio toggle at the top of the + * imagery/location dialog. + */ +export const ImagerySourceToggle = ({ + value, + onChange, +}: { + value: ImagerySource; + onChange: (source: ImagerySource) => void; +}) => ( +
+ {IMAGERY_SOURCES.map((source) => { + const isSelected = value === source.value; + return ( + + ); + })} +
+); diff --git a/frontend/src/features/try-fair/components/imagery/custom-imagery-form.tsx b/frontend/src/features/try-fair/components/imagery/custom-imagery-form.tsx new file mode 100644 index 000000000..3ae543cb2 --- /dev/null +++ b/frontend/src/features/try-fair/components/imagery/custom-imagery-form.tsx @@ -0,0 +1,120 @@ +import { useMemo, useState } from "react"; +import { SHOELACE_SIZES, TileServiceType } from "@/enums"; +import { BBOX } from "@/types"; +import { getTileServerRegex } from "@/utils"; +import { XYZTileServerInput } from "@/components/shared/form/xyz-tile-server-input"; +import { useMapInstance } from "@/hooks/use-map-instance"; +import { MapComponent } from "@/components/map"; +import { MapIcon } from "@/components/ui/icons"; + +export type AppliedCustomImagery = { + tileUrl: string; + tileServiceType: TileServiceType; + bounds: BBOX | null; +}; + +/** + * Custom Imagery tab of the imagery/location dialog: tile service type + tile + * server URL (via the shared XYZTileServerInput), and Apply. The map below + * previews the tiles live as soon as the URL is valid (MapComponent's + * TileServiceLayer adds the layer and frames the imagery from its TileJSON). + * Apply commits the imagery so the main map uses it. + */ +export const CustomImageryForm = ({ + applied, + onApply, +}: { + applied: AppliedCustomImagery | null; + onApply: (imagery: AppliedCustomImagery) => void; +}) => { + const { mapContainerRef, map } = useMapInstance(false, false); + + const [tileServiceType, setTileServiceType] = useState( + applied?.tileServiceType ?? TileServiceType.XYZ, + ); + const [tileServerURL, setTileServerURL] = useState( + applied?.tileUrl ?? "", + ); + // Remount key: bump it to reset the (uncontrolled) tile-type Select on clear. + const [formKey, setFormKey] = useState(0); + + // Validity is derived straight from the URL + type, so it updates on every + // keystroke (the previous approach read the web component's validity one tick + // late, which made the preview feel unresponsive until the field was cleared). + const isValid = useMemo(() => { + const url = tileServerURL.trim(); + const valid = + url.length > 0 && getTileServerRegex(tileServiceType).test(url); + return { + valid, + message: + valid || url.length === 0 ? "" : "Enter a valid tile server URL.", + }; + }, [tileServerURL, tileServiceType]); + + const clearForm = () => { + setTileServerURL(""); + setTileServiceType(TileServiceType.XYZ); + setFormKey((k) => k + 1); + }; + + const handleApply = () => { + if (!isValid.valid) return; + // TMS endpoints do not describe an imagery extent. Keeping the preview + // viewport as their bounds would make the main map fly to an arbitrary + // location, so let the user choose the area instead. + const b = map?.getBounds(); + const bounds: BBOX | null = b + ? tileServiceType === TileServiceType.TMS + ? null + : [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()] + : null; + onApply({ tileUrl: tileServerURL, tileServiceType, bounds }); + clearForm(); + }; + + return ( +
+ + + {/* The preview map stays mounted; the empty state overlays it until the + URL is valid, so the map instance is never torn down. */} +
+ + {!isValid.valid && ( +
+ +
+ )} +
+
+ ); +}; + +const CustomImageEmptyState = () => ( +
+ +

No Imagery to preview

+

+ Once all fields are populated correctly, the imagery will be displayed + here +

+
+); diff --git a/frontend/src/features/try-fair/components/imagery/imagery-location-modal.tsx b/frontend/src/features/try-fair/components/imagery/imagery-location-modal.tsx new file mode 100644 index 000000000..3e6b76bbe --- /dev/null +++ b/frontend/src/features/try-fair/components/imagery/imagery-location-modal.tsx @@ -0,0 +1,188 @@ +import { Dialog } from "@/components/ui/dialog"; +import { Map as MapLibreMap } from "maplibre-gl"; + +import { SHOELACE_SIZES } from "@/enums"; +import { BBOX } from "@/types"; +import { ImagerySourceToggle } from "@/features/try-fair/components/imagery/choose-imagery-source"; +import { useEffect, useRef, useState } from "react"; +import { + AppliedCustomImagery, + CustomImageryForm, +} from "@/features/try-fair/components/imagery/custom-imagery-form"; +import { + OamImageryMap, + SelectedCell, +} from "@/features/try-fair/components/imagery/oam-imagery-map"; +import { + GeocodeResult, + getImageryTileUrl, + OAMImageryItem, + searchImagery, +} from "@/features/try-fair/api/hot-imagery"; +import { OAMImageryPanel } from "@/features/try-fair/components/imagery/imagery-search-panel"; +import { ImagerySelection } from "@/features/try-fair/types/imagery-types"; +import { Divider } from "@/components/ui/divider"; +import { cn } from "@/utils"; +import { LocationSearch } from "./location-search"; + +export enum ImagerySource { + OPEN_AERIAL_MAP = "openAerialMap", + CUSTOM = "custom", +} + +/** + * "Imagery/location to map" dialog. Lets the user pick imagery either by + * browsing OpenAerialMap (search + footprints + preview, backed by + * imagery.hotosm.org) or by providing a custom XYZ/TMS tile server URL. + */ +export const ImageryLocationDialog = ({ + isOpened, + closeDialog, + onApply, +}: { + isOpened: boolean; + closeDialog: () => void; + onApply: (selection: ImagerySelection) => void; +}) => { + const [source, setSource] = useState( + ImagerySource.OPEN_AERIAL_MAP, + ); + const [selectedCell, setSelectedCell] = useState(null); + const [cellImages, setCellImages] = useState([]); + const [cellLoading, setCellLoading] = useState(false); + const [selectedItem, setSelectedItem] = useState(null); + const [appliedCustomImagery, setAppliedCustomImagery] = + useState(null); + const [showSearch, setShowSearch] = useState(false); + const mapRef = useRef(null); + const searchAbortRef = useRef(null); + + // Fetch the imagery inside the selected grid cell. + useEffect(() => { + searchAbortRef.current?.abort(); + setSelectedItem(null); + if (!selectedCell) { + setCellImages([]); + return; + } + const controller = new AbortController(); + searchAbortRef.current = controller; + setCellLoading(true); + searchImagery({ bbox: selectedCell.bbox, signal: controller.signal }) + .then((items) => { + if (!controller.signal.aborted) setCellImages(items); + }) + .catch(() => { + if (!controller.signal.aborted) setCellImages([]); + }) + .finally(() => { + if (!controller.signal.aborted) setCellLoading(false); + }); + return () => controller.abort(); + }, [selectedCell]); + + const handleApplyOAMItem = () => { + if (!selectedItem) return; + onApply({ + source: ImagerySource.OPEN_AERIAL_MAP, + item: selectedItem, + tileUrl: getImageryTileUrl(selectedItem.id, selectedItem.assetName), + bounds: selectedItem.bbox, + }); + closeDialog(); + }; + + const handleApplyCustomImagery = (imagery: AppliedCustomImagery) => { + setAppliedCustomImagery(imagery); + onApply({ + source: ImagerySource.CUSTOM, + tileUrl: imagery.tileUrl, + tileServiceType: imagery.tileServiceType, + bounds: imagery.bounds, + }); + }; + // Frame a picked search suggestion. + const handlePick = (result: GeocodeResult) => { + mapRef.current?.fitBounds(result.bbox as BBOX, { padding: 40 }); + }; + // Clearing the search returns to the world coverage view and clears/closes the panel. + const handleClearSearch = () => { + setSelectedCell(null); + setSelectedItem(null); + mapRef.current?.flyTo({ center: [0, 20], zoom: 1.4 }); + }; + // const {isLargeScreen} = useScreenSize() + const isOAM = source === ImagerySource.OPEN_AERIAL_MAP; + return ( + + {isOpened && ( +
+

+ Select an imagery source to preview and map your location. You can + choose pre-existing imagery from OpenAerialMap or enter a custom + tile server URL. +

+ + {!isOAM && } + +
+
+ { + mapRef.current = map; + }} + onToggleSearch={() => setShowSearch((prev) => !prev)} + /> +
+ {isOAM ? ( + <> +
+ setShowSearch(false)} + /> +
+ + setSelectedCell(null)} + handleApplyOAMItem={handleApplyOAMItem} + /> + + ) : ( +
+ +
+ )} +
+
+ )} +
+ ); +}; diff --git a/frontend/src/features/try-fair/components/imagery/imagery-modal-map.layers.ts b/frontend/src/features/try-fair/components/imagery/imagery-modal-map.layers.ts new file mode 100644 index 000000000..f2dd6e110 --- /dev/null +++ b/frontend/src/features/try-fair/components/imagery/imagery-modal-map.layers.ts @@ -0,0 +1,291 @@ +/** + * MapLibre plumbing shared by the imagery/location dialog's two maps — + * {@link OamImageryMap} and {@link CustomImageryMap}. + * + * Everything in here operates imperatively on a MapLibre `Map` — adding + * sources/layers and previewing rasters. The React components stay declarative + * and just call these helpers from their effects, so the "what happens when" + * (components) is separated from the "how" (this file). + * + * Layer stacking, bottom → top: + * basemap → density grid → count labels → selected-cell highlight → preview + * The image preview is added last so it sits above the grid. + */ +import maplibregl, { + GeoJSONSource, + Map as MapLibreMap, + PointLike, + StyleSpecification, +} from "maplibre-gl"; +import { FetchSource, PMTiles, Protocol } from "pmtiles"; +import { BBOX } from "@/types"; +import { + HOT_IMAGERY_DENSITY_PMTILES_URL, + HOT_IMAGERY_DENSITY_SOURCE_LAYER, + MAX_ZOOM_LEVEL, +} from "@/config"; +import { + getImageryTileUrl, + OAMImageryItem, +} from "@/features/try-fair/api/hot-imagery"; + +// ── Ids ─────────────────────────────────────────────────────────────────────── +// Prefixed so they never collide with the try-fAIr map's own sources/layers. + +const SOURCES = { + basemap: "modal-basemap", + density: "modal-oam-density", + cell: "modal-selected-cell", + imagery: "modal-oam-selected", +} as const; + +const LAYERS = { + basemap: "modal-basemap-layer", + densityFill: "modal-density-fill", + densityCount: "modal-density-count", + cellFill: "modal-selected-cell-fill", + cellLine: "modal-selected-cell-line", + imagery: "modal-oam-selected-layer", +} as const; + +// ── Styling ─────────────────────────────────────────────────────────────────── + +// Blue choropleth ramp keyed on each cell's image `count`: pale where sparse, +// deep where dense. Pairs are [count, color], fed to an `interpolate`. +const DENSITY_COLOR_RAMP: (string | number)[] = [ + 1, + "#cbdbe3", + 5, + "#94b6c7", + 20, + "#5f93ab", + 50, + "#3f7d99", +]; + +const CELL_HIGHLIGHT_COLOR = "#D63F40"; + +const EMPTY_COLLECTION: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: [], +}; + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** + * A density grid cell the user selected: its aggregated image extent (`bbox`), + * image `count`, and the cell polygon `geometry` used to paint the highlight. + */ +export type SelectedCell = { + bbox: BBOX; + count: number; + geometry: GeoJSON.Geometry; +}; + +/** Properties baked into each density cell by the imagery.hotosm.org generator. */ +type DensityCellProps = { + count?: number; + bboxW?: number; + bboxS?: number; + bboxE?: number; + bboxN?: number; +}; + +// ── Map creation ────────────────────────────────────────────────────────────── + +// Register the pmtiles:// scheme handler exactly once for the app lifetime. +// A single Protocol.tile serves the archive; it is pre-added only to pin +// no-cache so a re-uploaded file doesn't error on a stale If-Range +// revalidation. Registering per dialog-open (and removing on close) would tear +// the scheme down globally — and churn under React StrictMode — silently +// breaking the vector source. +let pmtilesProtocolRegistered = false; +const registerPmtilesProtocol = () => { + if (pmtilesProtocolRegistered) return; + const protocol = new Protocol(); + const src = new FetchSource(HOT_IMAGERY_DENSITY_PMTILES_URL); + src.chromeWindowsNoCache = true; + protocol.add(new PMTiles(src)); + maplibregl.addProtocol("pmtiles", protocol.tile); + pmtilesProtocolRegistered = true; +}; + +const baseStyle: StyleSpecification = { + version: 8, + // Needed so the density count labels can render text. demotiles serves the + // Noto Sans stack (Open Sans is not available there). + glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf", + sources: { + [SOURCES.basemap]: { + type: "raster", + tiles: [ + "https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", + "https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", + ], + tileSize: 256, + attribution: "© OpenStreetMap © CARTO", + }, + }, + layers: [{ id: LAYERS.basemap, type: "raster", source: SOURCES.basemap }], +}; + +/** Create the shared modal map (CARTO light basemap, world view). */ +export const createImageryMap = (container: HTMLDivElement): MapLibreMap => { + registerPmtilesProtocol(); + return new maplibregl.Map({ + container, + style: baseStyle, + center: [0, 20], + zoom: 1.4, + minZoom: 1, + maxZoom: MAX_ZOOM_LEVEL, + pitchWithRotate: false, + attributionControl: false, + }); +}; + +// ── Density grid + cell highlight ───────────────────────────────────────────── + +/** + * Add the OpenAerialMap density grid and the (initially empty) selected-cell + * highlight. Call once, after the map's `load` event. + */ +export const addImageryLayers = (map: MapLibreMap): void => { + map.addSource(SOURCES.density, { + type: "vector", + url: `pmtiles://${HOT_IMAGERY_DENSITY_PMTILES_URL}`, + }); + map.addLayer({ + id: LAYERS.densityFill, + type: "fill", + source: SOURCES.density, + "source-layer": HOT_IMAGERY_DENSITY_SOURCE_LAYER, + // Only draw populated polygon cells (the archive also carries label points). + filter: [ + "all", + ["==", ["geometry-type"], "Polygon"], + [">", ["get", "count"], 0], + ], + paint: { + "fill-color": [ + "interpolate", + ["linear"], + ["get", "count"], + ...DENSITY_COLOR_RAMP, + ], + "fill-opacity": 0.75, + "fill-outline-color": "#ffffff", + }, + }); + + // Image count per cell. The archive carries a Point label feature per cell + // (separate from the polygon, so tile clipping can't drift the label onto an + // edge), which is what we label here. + map.addLayer({ + id: LAYERS.densityCount, + type: "symbol", + source: SOURCES.density, + "source-layer": HOT_IMAGERY_DENSITY_SOURCE_LAYER, + filter: ["==", ["geometry-type"], "Point"], + layout: { + "text-field": ["to-string", ["get", "count"]], + "text-font": ["Noto Sans Bold"], + "text-size": 12, + "text-allow-overlap": false, + }, + paint: { + "text-color": "#1b3a4b", + "text-halo-color": "#ffffff", + "text-halo-width": 1.5, + }, + }); + + map.addSource(SOURCES.cell, { type: "geojson", data: EMPTY_COLLECTION }); + map.addLayer({ + id: LAYERS.cellFill, + type: "fill", + source: SOURCES.cell, + paint: { "fill-color": CELL_HIGHLIGHT_COLOR, "fill-opacity": 0.35 }, + }); + map.addLayer({ + id: LAYERS.cellLine, + type: "line", + source: SOURCES.cell, + paint: { "line-color": CELL_HIGHLIGHT_COLOR, "line-width": 1.5 }, + }); + + // Pointer cursor over clickable cells. + map.on("mouseenter", LAYERS.densityFill, () => { + map.getCanvas().style.cursor = "pointer"; + }); + map.on("mouseleave", LAYERS.densityFill, () => { + map.getCanvas().style.cursor = ""; + }); +}; + +/** + * The density cell under a click point, or null if the click missed the grid. + * `bboxW/S/E/N` (the aggregated image extent) is what the picker searches, not + * the wider cell square; the cell polygon geometry is carried for the highlight. + */ +export const readCellAt = ( + map: MapLibreMap, + point: PointLike, +): SelectedCell | null => { + const hit = map.queryRenderedFeatures(point, { + layers: [LAYERS.densityFill], + })[0]; + const p = hit?.properties as DensityCellProps | undefined; + if (!hit || !p || !p.count || p.bboxW == null) return null; + return { + bbox: [p.bboxW, p.bboxS!, p.bboxE!, p.bboxN!], + count: p.count, + geometry: hit.geometry, + }; +}; + +/** Paint (or clear, with null) the red highlight over the selected cell. */ +export const highlightCell = ( + map: MapLibreMap, + geometry: GeoJSON.Geometry | null, +): void => { + const src = map.getSource(SOURCES.cell) as GeoJSONSource | undefined; + src?.setData( + geometry + ? { + type: "FeatureCollection", + features: [{ type: "Feature", properties: {}, geometry }], + } + : EMPTY_COLLECTION, + ); +}; + +// ── Raster previews ─────────────────────────────────────────────────────────── + +const removeLayerAndSource = ( + map: MapLibreMap, + layerId: string, + sourceId: string, +): void => { + if (map.getLayer(layerId)) map.removeLayer(layerId); + if (map.getSource(sourceId)) map.removeSource(sourceId); +}; + +/** Preview a selected OAM image's raster tiles and frame it. */ +export const showImageryPreview = ( + map: MapLibreMap, + item: OAMImageryItem, +): void => { + removeLayerAndSource(map, LAYERS.imagery, SOURCES.imagery); + map.addSource(SOURCES.imagery, { + type: "raster", + tiles: [getImageryTileUrl(item.id, item.assetName)], + tileSize: 256, + bounds: item.bbox, // don't request tiles outside the image extent + }); + map.addLayer({ id: LAYERS.imagery, type: "raster", source: SOURCES.imagery }); + map.fitBounds(item.bbox, { padding: 40, maxZoom: 18 }); +}; + +export const clearImageryPreview = (map: MapLibreMap): void => + removeLayerAndSource(map, LAYERS.imagery, SOURCES.imagery); diff --git a/frontend/src/features/try-fair/components/imagery/imagery-search-panel.tsx b/frontend/src/features/try-fair/components/imagery/imagery-search-panel.tsx new file mode 100644 index 000000000..88e8f64ca --- /dev/null +++ b/frontend/src/features/try-fair/components/imagery/imagery-search-panel.tsx @@ -0,0 +1,238 @@ +import { useMemo, useState } from "react"; +import { cn, extractDatePart } from "@/utils"; +import { Spinner } from "@/components/ui/spinner"; +import { OAMImageryItem } from "@/features/try-fair/api/hot-imagery"; +import { ExpandIcon } from "@/components/ui/icons/expand-icon"; +import { CloseIcon } from "@/components/ui/icons"; +import { Select } from "@/components/ui/form"; +import { SHOELACE_SELECT_SIZES } from "@/enums"; +import { + DatePreset, + ResolutionPreset, +} from "@/features/try-fair/types/imagery-types"; +import { + IMAGERY_DATE_OPTIONS, + IMAGERY_RESOLUTION_PRESETS, + withinDate, + withinResolution, +} from "@/features/try-fair/utils/common"; +import { Button } from "@/components/ui/button"; +import { ToolTip } from "@/components/ui/tooltip"; +import { EmptyGridIcon } from "@/components/ui/icons/empty-grid-icon"; + +const formatGsd = (gsd: number | null): string => { + if (gsd == null) return "N/A"; + return gsd < 1 ? `${Math.round(gsd * 100)} cm` : `${gsd.toFixed(1)} m`; +}; + +const formatDate = (iso: string | null): string => + iso ? extractDatePart(iso) : "Unknown date"; + +const FilterSelect = ({ + value, + options, + onChange, + label, +}: { + value: V; + options: { label: string; value: V }[]; + onChange: (v: V) => void; + label: string; +}) => { + const mappedOptions = useMemo( + () => options.map((o) => ({ name: o.label, value: o.value })), + [options], + ); + + return ( + setQuery(e.target.value)} + onFocus={() => results.length > 0 && setOpen(true)} + placeholder="Search for a place to map" + className="flex-1 px-3 py-2.5 text-sm text-dark outline-none min-w-0" + /> + {loading && ( + + + + )} + {query && ( + + )} + + + + + + {open && results.length > 0 && ( +
    + {results.map((r, i) => ( +
  • + +
  • + ))} +
+ )} + + ); +}; diff --git a/frontend/src/features/try-fair/components/imagery/oam-imagery-map.tsx b/frontend/src/features/try-fair/components/imagery/oam-imagery-map.tsx new file mode 100644 index 000000000..3d4fd5536 --- /dev/null +++ b/frontend/src/features/try-fair/components/imagery/oam-imagery-map.tsx @@ -0,0 +1,101 @@ +import { useEffect, useRef } from "react"; +import { Map as MapLibreMap, MapMouseEvent } from "maplibre-gl"; +import { MapComponent } from "@/components/map"; +import { OAMImageryItem } from "@/features/try-fair/api/hot-imagery"; +import { useImageryModalMap } from "@/features/try-fair/hooks/use-imagery-modal-map"; +import { + addImageryLayers, + clearImageryPreview, + highlightCell, + readCellAt, + showImageryPreview, + SelectedCell, +} from "./imagery-modal-map.layers"; +import { SearchIcon } from "@/components/ui/icons"; +import { ToolTip } from "@/components/ui/tooltip"; + +export type { SelectedCell }; + +type Props = { + /** Cell to outline in red, or null. Driven by the dialog so closing the + * panel or picking an image clears it. */ + highlightGeometry: GeoJSON.Geometry | null; + /** OAM image whose tiles should be previewed on the map. */ + selectedItem: OAMImageryItem | null; + /** Fired when a density grid cell is clicked (or cleared by clicking away). */ + onCellSelect: (cell: SelectedCell | null) => void; + /** Handed the map once ready, e.g. so the dialog can drive fitBounds on search. */ + onMapReady?: (map: MapLibreMap) => void; + /** Fired when the map's search button is clicked to toggle location search. */ + onToggleSearch?: () => void; + searchIconTooltipContent: string; +}; + +/** + * The OpenAerialMap tab's map: the imagery.hotosm.org coverage grid, each cell + * labelled with its image count. Clicking a cell reports it upward so the dialog + * can list the imagery inside; the selected image is previewed as raster tiles. + * + * MapLibre plumbing lives in ./imagery-modal-map.layers.ts, so each effect + * below reads as one action. + */ +export const OamImageryMap = ({ + highlightGeometry, + selectedItem, + onCellSelect, + onMapReady, + onToggleSearch, + searchIconTooltipContent, +}: Props) => { + const { map, mapContainerRef } = useImageryModalMap(); + + const onCellSelectRef = useRef(onCellSelect); + onCellSelectRef.current = onCellSelect; + + // Once ready: add the density grid + labels, and wire click-to-select. + useEffect(() => { + if (!map) return; + addImageryLayers(map); + onMapReady?.(map); + + const handleClick = (e: MapMouseEvent) => { + onCellSelectRef.current(readCellAt(map, e.point)); + }; + map.on("click", handleClick); + return () => { + map.off("click", handleClick); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); + + // Red highlight follows the dialog's selection. + useEffect(() => { + if (map) highlightCell(map, highlightGeometry); + }, [map, highlightGeometry]); + + // Preview (and frame) the selected image, or clear it. + useEffect(() => { + if (!map) return; + if (selectedItem) showImageryPreview(map, selectedItem); + else clearImageryPreview(map); + }, [map, selectedItem]); + + return ( +
+ +
+ + + +
+
+
+ ); +}; diff --git a/frontend/src/features/try-fair/components/map/try-fair-map.tsx b/frontend/src/features/try-fair/components/map/try-fair-map.tsx index 5c57569a3..db55d07c0 100644 --- a/frontend/src/features/try-fair/components/map/try-fair-map.tsx +++ b/frontend/src/features/try-fair/components/map/try-fair-map.tsx @@ -18,6 +18,10 @@ import useScreenSize from "@/hooks/use-screen-size"; import { LocateGridIcon } from "@/components/ui/icons/locate-grid-icon"; import { TryFairDownloadButton } from "@/features/try-fair/components/map/try-fair-download-button"; import { cn } from "@/utils"; +import { GlobeSearchIcon } from "@/components/ui/icons/globe-search-icon"; +import { useStartMappingStore } from "@/features/try-fair/utils/start-mapping-store"; +import { useAuth } from "@/app/providers/auth-provider"; +import { DISABLE_AUTH_ON_TRY_FAIR } from "@/config"; type TryFairMapProps = { map: Map | null; @@ -60,18 +64,15 @@ export const TryFairMap = ({ onHelp, }: TryFairMapProps) => { const { isSmallViewport } = useScreenSize(); + const { setShowChooseLocationModal, setShowSigninModal } = + useStartMappingStore(); + const { isAuthenticated: _isAuthenticated } = useAuth(); + const isAuthenticated = DISABLE_AUTH_ON_TRY_FAIR || _isAuthenticated; const [choroplethBuckets, setChoroplethBuckets] = useState< ChoroplethBucket[] | null >(null); const gridBBoxRef = useRef(null); - - const handleBBoxChange = useCallback( - (bbox: BBOX, tileZoom: number) => { - gridBBoxRef.current = bbox; - onBBoxChange(bbox, tileZoom); - }, - [onBBoxChange], - ); + const fitPendingRef = useRef(false); const handleFitToGrid = useCallback(() => { if (!canFitToBounds) return; @@ -83,6 +84,33 @@ export const TryFairMap = ({ }); }, [map, canFitToBounds]); + const handleBBoxChange = useCallback( + (bbox: BBOX, tileZoom: number) => { + gridBBoxRef.current = bbox; + onBBoxChange(bbox, tileZoom); + // If a resolution change triggered a grid recalculation, fit now. + if (fitPendingRef.current) { + fitPendingRef.current = false; + if (map && canFitToBounds) { + map.fitBounds([bbox[0], bbox[1], bbox[2], bbox[3]], { + padding: 40, + essential: true, + }); + } + } + }, + [onBBoxChange, map, canFitToBounds], + ); + + // When resolution changes, flag that we want to fit once the grid recalculates. + const prevResolutionRef = useRef(resolution); + useEffect(() => { + if (resolution !== prevResolutionRef.current) { + prevResolutionRef.current = resolution; + fitPendingRef.current = true; + } + }, [resolution]); + useEffect(() => { if (!map) return; if (isPredicting) { @@ -155,6 +183,27 @@ export const TryFairMap = ({ {map && (
+ + + + {/* Group 1: Zoom In, Zoom Out, Fit to bounds */}
{ + return ( + +
+
+ +

Basic

+
+ {/* */} +
+
+ ); +}; + +export default MappingMode; diff --git a/frontend/src/features/try-fair/components/modals/share-project-modal.tsx b/frontend/src/features/try-fair/components/modals/share-project-modal.tsx new file mode 100644 index 000000000..574133fe0 --- /dev/null +++ b/frontend/src/features/try-fair/components/modals/share-project-modal.tsx @@ -0,0 +1,102 @@ +import Dialog from "@/components/ui/dialog/dialog"; +import { Button } from "@/components/ui/button"; +import { CloseIcon, InfoIcon, LinkIcon } from "@/components/ui/icons"; +import { SHOELACE_SIZES } from "@/enums"; +import { useStartMappingStore } from "@/features/try-fair/utils/start-mapping-store"; +import useCopyToClipboard from "@/hooks/use-clipboard"; +import { CheckIcon } from "@/components/ui/icons"; + +interface ShareProjectModalProps { + isOpened?: boolean; + closeDialog?: () => void; +} + +export const ShareProjectModal: React.FC = ({ + isOpened: externalIsOpened, + closeDialog: externalCloseDialog, +}) => { + const storeIsOpened = useStartMappingStore((state) => state.showShareModal); + const storeSetIsOpened = useStartMappingStore( + (state) => state.setShowShareModal, + ); + + const isOpened = externalIsOpened ?? storeIsOpened; + const { copyToClipboard, isCopied } = useCopyToClipboard(); + + const currentUrl = typeof window !== "undefined" ? window.location.href : ""; + + const handleClose = () => { + if (externalCloseDialog) { + externalCloseDialog(); + } else { + storeSetIsOpened(false); + } + }; + + return ( + +
+ {/* Header */} +
+
+ +

Share session

+
+ +
+ + {/* URL preview pill */} +
+ + + {currentUrl} + +
+ + {/* Copy button */} + + + {/* Info note */} +
+ +

+ Anyone with this link can open the same session — model, imagery, + resolution and parameters will all be restored. +

+
+
+
+ ); +}; diff --git a/frontend/src/features/try-fair/components/modals/sign-in-prompt.tsx b/frontend/src/features/try-fair/components/modals/sign-in-prompt.tsx new file mode 100644 index 000000000..8e4203766 --- /dev/null +++ b/frontend/src/features/try-fair/components/modals/sign-in-prompt.tsx @@ -0,0 +1,54 @@ +import { Dialog } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { ButtonVariant, SHOELACE_SIZES } from "@/enums"; +import { useLogin } from "@/hooks/use-login"; +import { CautionIcon } from "@/components/ui/icons/caution-icon"; + +/** + * Prompt shown when an unauthenticated user tries to change the + * imagery/location on the try-fAIr page. + */ +export const SignInPromptDialog = ({ + isOpened, + closeDialog, +}: { + isOpened: boolean; + closeDialog: () => void; +}) => { + const { handleLogin, loading } = useLogin(); + + return ( + +
+
+ +
+ +

+ You must sign in to change the imagery. +

+ +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/features/try-fair/components/model-picker-modal.tsx b/frontend/src/features/try-fair/components/model-picker-modal.tsx index 1a77968a7..b747f18f2 100644 --- a/frontend/src/features/try-fair/components/model-picker-modal.tsx +++ b/frontend/src/features/try-fair/components/model-picker-modal.tsx @@ -1,5 +1,6 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { BaseModelStacItem } from "@/features/try-fair/api/stac"; +import { useImageryCountry } from "@/features/try-fair/hooks/use-imagery-country"; import DropDown from "@/components/ui/dropdown/dropdown"; import { useDropdownMenu } from "@/hooks/use-dropdown-menu"; import { ChevronDownIcon } from "@/components/ui/icons"; @@ -8,6 +9,14 @@ import { TreesIcon } from "@/components/ui/icons/trees-icon"; import { SwimmingPoolIcon } from "@/components/ui/icons/swimming-pool-icon"; import { ParkingIcon } from "@/components/ui/icons/parking-icon"; import { IconProps } from "@/types"; +import { Button } from "@/components/ui/button"; +import { GlobeSearchIcon } from "@/components/ui/icons/globe-search-icon"; +import { ModelType } from "@/enums"; +import { useStartMappingStore } from "@/features/try-fair/utils/start-mapping-store"; +import { useAuth } from "@/app/providers/auth-provider"; +import { DISABLE_AUTH_ON_TRY_FAIR } from "@/config"; +import { ImagerySource } from "@/features/try-fair/components/imagery/imagery-location-modal"; +import { flagEmoji } from "@/features/try-fair/utils/common"; type ModelPickerProps = { selectedModel: BaseModelStacItem | null; @@ -38,6 +47,24 @@ const FeatureBadge = ({ label }: { label: string | undefined }) => { ); }; +/** Radio indicator: filled primary dot when selected, empty grey ring otherwise. */ +const RadioDot = ({ selected }: { selected: boolean }) => ( + + {selected && } + +); + +const CountryBadge = ({ country, code }: { country: string; code: string }) => ( + + {flagEmoji(code)} + {country} + +); + export const ModelPicker: React.FC = ({ selectedModel, onSelect, @@ -55,25 +82,28 @@ export const ModelPicker: React.FC = ({ ] .filter(Boolean) .join(", "); + const { currentModelType, selectedImagery } = useStartMappingStore(); - const handleSelect = (model: BaseModelStacItem) => { - onSelect(model); - - if (isSmallViewport) { - setIsOpen(false); - return; - } - - setTimeout(() => { - onDropdownHide(); - }, 200); - }; + // When imagery is the active source, the trigger shows the imagery's name + // instead of the default-location model title. + const showImagery = + currentModelType === ModelType.IMAGERY && !!selectedImagery; + const imageryName = + selectedImagery?.source === ImagerySource.OPEN_AERIAL_MAP + ? selectedImagery.item.title + : "Custom Imagery"; const trigger = (
{loading ? (

Loading models…

+ ) : showImagery ? ( +

+ {imageryName} +

+ ) : models.length === 0 ? ( +

No models available

) : selectedModel ? ( <>

@@ -124,9 +154,13 @@ export const ModelPicker: React.FC = ({

{ }
@@ -138,60 +172,192 @@ export const ModelPicker: React.FC = ({ * Standalone content for the model picker, exported so it can be rendered * inside a page-level Dialog (outside the MobileDrawer). */ +// Sentinel key for the selected imagery (model ids are STAC ids, never this). +const IMAGERY_KEY = "imagery"; + +type StagedChoice = + | { type: "model"; model: BaseModelStacItem } + | { type: "imagery" }; + export const ModelPickerContent = ({ selectedModel, onSelect, models, + onClose, }: { selectedModel: BaseModelStacItem | null; onSelect: (model: BaseModelStacItem) => void; models: BaseModelStacItem[]; -}) => ( -
-
- {models.map((model) => { - const isSelected = selectedModel?.id === model.id; - const baseModelTitle = model?.properties?.["fair:base_model_title"]; - const coverage = model?.properties?.["fair:coverage"]; - return ( - + )} +
+

Default Locations

+
+
+ +
+ {models.length > 0 ? ( + models.map((model) => { + const isSelected = activeKey === model.id; + return ( +
- {baseModelTitle && ( -

- Model: {baseModelTitle} -

- )} - {coverage && ( -

{coverage}

- )} -

- By: {model?.properties?.providers[0]?.name ?? ""} +

+

+ {model?.properties?.title ?? ""} +

+ + +
+

+ Model: {model?.properties?.["mlm:name"] ?? ""} +

+

+ By: {model?.properties?.providers[0]?.name ?? ""} +

+ + + ); + }) + ) : ( +
+

+ No models available +

+

+ There are currently no models available for use.{" "}

- - - ); - })} +
+ )} +
+
+ + + +
-
-); + ); +}; diff --git a/frontend/src/features/try-fair/components/start-mapping/export-map-results.tsx b/frontend/src/features/try-fair/components/start-mapping/export-map-results.tsx new file mode 100644 index 000000000..0f484f848 --- /dev/null +++ b/frontend/src/features/try-fair/components/start-mapping/export-map-results.tsx @@ -0,0 +1,97 @@ +import { DropDown } from "@/components/ui/dropdown"; +import { DropdownMenuItem } from "@/components/ui/dropdown/dropdown"; +import { + ChevronDownIcon, + CloudDownloadIcon, + MapIcon, +} from "@/components/ui/icons"; +import { DropdownPlacement, TryFairMapOutputType } from "@/enums"; +import { useStartMappingStore } from "@/features/try-fair/utils/start-mapping-store"; +import { + buildChoropleth, + toPointCollection, +} from "@/features/try-fair/utils/helpers"; +import { geoJSONDowloader } from "@/utils/geo/geo-utils"; + +const getDownloadData = ( + predictions: GeoJSON.FeatureCollection, + outputType: TryFairMapOutputType, + predictionBBox: [number, number, number, number] | null, + predictionGridZoom: number | null, +): GeoJSON.FeatureCollection => { + if (outputType === TryFairMapOutputType.POINTS) { + return toPointCollection(predictions); + } + if (outputType === TryFairMapOutputType.CLUSTER && predictionBBox) { + return buildChoropleth( + predictions, + predictionBBox, + predictionGridZoom ?? undefined, + ); + } + return predictions; +}; + +const ExportMapResults = () => { + const { + setDownloadType, + predictions, + outputType, + predictionBBox, + predictionGridZoom, + } = useStartMappingStore(); + + const hasPredictions = Boolean(predictions?.features?.length); + + const handleSelect = (value: string) => { + if (value === "download") { + if (!predictions) return; + const exportData = getDownloadData( + predictions, + outputType, + predictionBBox, + predictionGridZoom, + ); + geoJSONDowloader( + exportData, + `fair-predictions-${outputType.toLowerCase()}`, + ); + return; + } + setDownloadType(value); + }; + + const menuItems: DropdownMenuItem[] = [ + { + label: "Download Map Result", + value: "download", + Icon: CloudDownloadIcon, + disabled: !hasPredictions, + onClick: () => handleSelect("download"), + }, + { + label: "Map Large Area", + value: "large-area", + Icon: MapIcon, + dividerBefore: true, + onClick: () => handleSelect("large-area"), + }, + ]; + + return ( + + Export + + + } + /> + ); +}; + +export default ExportMapResults; diff --git a/frontend/src/features/try-fair/components/start-mapping/map-large-area-modal.tsx b/frontend/src/features/try-fair/components/start-mapping/map-large-area-modal.tsx new file mode 100644 index 000000000..12a3d0ee1 --- /dev/null +++ b/frontend/src/features/try-fair/components/start-mapping/map-large-area-modal.tsx @@ -0,0 +1,209 @@ +import { MapComponent } from "@/components/map"; +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { DeleteIcon, UploadIcon } from "@/components/ui/icons"; +import { DrawIcon } from "@/components/ui/icons/draw-icon"; +import { PictureIcon } from "@/components/ui/icons/picture-icon"; +import { ControlsPosition, DrawingModes, SHOELACE_SIZES } from "@/enums"; +import { + AOITab, + useMapLargeArea, +} from "@/features/try-fair/hooks/use-map-large-area"; +import { BBOX, IconProps } from "@/types"; +import { cn } from "@/utils"; +import { ToolTip } from "@/components/ui/tooltip"; + +// ── Tabs ──────────────────────────────────────────────────────────────────────── + +const TABS: { value: AOITab; label: string; Icon: React.FC }[] = [ + { value: "whole", label: "Map Whole Area", Icon: PictureIcon }, + { value: "draw", label: "Draw Specific Area", Icon: DrawIcon }, + { value: "upload", label: "Upload Area of Interest", Icon: UploadIcon }, +]; + +const MapLargeAreaContent = ({ + tileServerURL, + imageryBounds, + onSubmit, + closeDialog, +}: { + tileServerURL?: string; + imageryBounds?: BBOX | null; + onSubmit: () => void; + closeDialog: () => void; +}) => { + const { + mapContainerRef, + map, + drawingMode, + setDrawingMode, + terraDraw, + activeTab, + selectedAOI, + uploadedFileName, + fileInputRef, + isSubmittingMapLargeArea, + handleTabChange, + handleFileChange, + handleClearArea, + handleEnableDrawing, + handleSubmit, + } = useMapLargeArea({ + imageryBounds, + onSubmit, + closeDialog, + }); + + return ( +
+ {/* Hidden file input for native OS file selection */} + + + {/* Header Tabs */} +
+ {TABS.map(({ value, label, Icon }) => ( + + ))} +
+ + {/* Map Container */} +
+ + + {/* Selected AOI Status Floating Badge (Top Right) */} + {selectedAOI && ( +
+ {activeTab === "whole" ? ( + + ) : activeTab === "draw" ? ( + + ) : ( + + )} + + {activeTab === "upload" + ? uploadedFileName || "Mapping AOI.geojson" + : activeTab === "draw" + ? "Drawn AOI" + : "Whole Imagery AOI"} + + {activeTab !== "whole" && ( + + )} +
+ )} + + {/* Floating draw toggle button – only visible in draw mode */} + {map && activeTab === "draw" && ( +
+ + + +
+ )} +
+ + {/* Footer Controls */} +
+ +
+
+ ); +}; + +export const MapLargeAreaModal = ({ + isOpened, + closeDialog, + tileServerURL, + imageryBounds, + onSubmit, +}: { + isOpened: boolean; + closeDialog: () => void; + tileServerURL?: string; + imageryBounds: BBOX | null; + onSubmit: () => void; +}) => { + return ( + + {isOpened && ( + + )} + + ); +}; diff --git a/frontend/src/features/try-fair/components/start-mapping/map-large-area-request-success-dialog.tsx b/frontend/src/features/try-fair/components/start-mapping/map-large-area-request-success-dialog.tsx new file mode 100644 index 000000000..33d43bb86 --- /dev/null +++ b/frontend/src/features/try-fair/components/start-mapping/map-large-area-request-success-dialog.tsx @@ -0,0 +1,55 @@ +import { ModelFormConfirmation } from "@/assets/images"; +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { Image } from "@/components/ui/image"; +import { APPLICATION_ROUTES } from "@/constants"; +import { ButtonVariant, SHOELACE_SIZES } from "@/enums"; +import { useNavigate } from "react-router-dom"; + +/** Confirmation shown after a Try fAIr Map Large Area request is submitted. */ +export const MapLargeAreaRequestSuccess = ({ + isOpen, + onClose, +}: { + isOpen: boolean; + onClose: () => void; +}) => { + const navigate = useNavigate(); + + return ( + +
+
+ Success Icon +
+

Map Area Request Sent

+

+ We have received your request to map the selected area. You will be + notified when the prediction is ready. +

+
+ + +
+
+
+ ); +}; diff --git a/frontend/src/features/try-fair/components/try-fair-nav-links.tsx b/frontend/src/features/try-fair/components/try-fair-nav-links.tsx new file mode 100644 index 000000000..3ae6be8db --- /dev/null +++ b/frontend/src/features/try-fair/components/try-fair-nav-links.tsx @@ -0,0 +1,35 @@ +import { useStartMappingStore } from "@/features/try-fair/utils/start-mapping-store"; + +const startMappingLinks = [ + // { + // title: "Help", + // value: "help", + // }, + { + title: "Share", + value: "share", + }, +]; +export const StartMappingNavlinks: React.FC = () => { + const setShowShareModal = useStartMappingStore( + (state) => state.setShowShareModal, + ); + + return ( +
    + {startMappingLinks.map((link) => ( +
  • + +
  • + ))} +
+ ); +}; diff --git a/frontend/src/features/try-fair/components/try-fair-sidebar.tsx b/frontend/src/features/try-fair/components/try-fair-sidebar.tsx index dbb870a29..5a6bd643b 100644 --- a/frontend/src/features/try-fair/components/try-fair-sidebar.tsx +++ b/frontend/src/features/try-fair/components/try-fair-sidebar.tsx @@ -1,4 +1,4 @@ -import { TryFairMapOutputType, TryFairResolution } from "@/enums/try-fair"; +import { ModelType, TryFairMapOutputType, TryFairResolution } from "@/enums"; import { ModelPicker } from "./model-picker-modal"; import { TRY_FAIR_PAGE_CONTENT } from "@/constants/ui-contents/try-fair-contents"; import { APP_TOUR_IDS } from "@/constants/site-tour"; @@ -8,7 +8,9 @@ import { ParametersIcon } from "@/components/ui/icons/parameters-icon"; import { SnowflakeIcon } from "@/components/ui/icons/snow-flake-icon"; import { GridIcon } from "@/components/ui/icons/grid-icon"; import { FlameIcon } from "@/components/ui/icons/flame-icon"; + import { + getAccuracyLabel, getModelOutputType, OUTPUT_TYPES, RESOLUTIONS, @@ -19,6 +21,10 @@ import { } from "@/features/try-fair/api/stac"; import { cn } from "@/utils"; import useScreenSize from "@/hooks/use-screen-size"; +import { RefreshIcon } from "@/components/ui/icons"; +import { ToolTip } from "@/components/ui/tooltip"; +import FeatureToMapDropdown from "@/features/try-fair/components/dropdowns/feature-to-map-dropdown"; +import { useStartMappingStore } from "@/features/try-fair/utils/start-mapping-store"; type TryFairSidebarProps = { selectedModel: BaseModelStacItem | null; @@ -32,9 +38,13 @@ type TryFairSidebarProps = { inferenceParams: InferenceParam[]; paramValues: Record; onParamChange: (key: string, value: number | string | boolean) => void; + onResetParameters: () => void; + isParametersDefault: boolean; onMap: () => void; isPredicting: boolean; isMapButtonDisabled: boolean; + feature: string; + onFeatureChange: (feature: string) => void; className?: string; openMobileModelPickerDialog?: () => void; }; @@ -51,17 +61,30 @@ export const TryFairSidebar = ({ inferenceParams, paramValues, onParamChange, + onResetParameters, + isParametersDefault, onMap, isPredicting, isMapButtonDisabled, + feature, + onFeatureChange, className, openMobileModelPickerDialog, }: TryFairSidebarProps) => { const { isSmallViewport } = useScreenSize(); - + const currentModelType = useStartMappingStore( + (state) => state.currentModelType, + ); const supportsPolygon = selectedModel ? getModelOutputType(selectedModel) === TryFairMapOutputType.POLYGON : true; + const confidenceParam = inferenceParams.find( + (param) => param.key === "confidence_threshold", + ); + const confidenceValue = + paramValues.confidence_threshold ?? confidenceParam?.spec.default ?? 0.7; + const confidenceMin = confidenceParam?.spec.min ?? 0; + const confidenceMax = confidenceParam?.spec.max ?? 1; return (
+ {currentModelType !== ModelType.DEMO && ( + + )} +

{TRY_FAIR_PAGE_CONTENT.sidebar.mapOutput.label} @@ -149,11 +180,28 @@ export const TryFairSidebar = ({ className="p-3 border bg-[#FAFAFA] rounded-lg border-gray-border space-y-4 flex flex-col" > {/* Section header */} -

- -

- {TRY_FAIR_PAGE_CONTENT.sidebar.parameters.label} -

+
+
+ +

+ {TRY_FAIR_PAGE_CONTENT.sidebar.parameters.label} +

+
+ + + +
{/* Description */} @@ -191,45 +239,46 @@ export const TryFairSidebar = ({
- {inferenceParams - .filter((p) => p.key === "confidence_threshold") - .map(({ key, spec }) => { - const value = paramValues[key] ?? spec.default; - const min = spec.min ?? 0; - const max = spec.max ?? 1; - - return ( -
-
-

Confidence

- - {Math.round(Number(value) * 100)}% - -
+
+
+

Accuracy

+ + {getAccuracyLabel(confidenceValue)} + +
-
- - - onParamChange(key, parseFloat(e.target.value)) - } - className="try-fair-confidence-slider disabled:cursor-wait flex-1 appearance-none cursor-pointer outline-none" - /> - -
-
- Low - High -
-
- ); - })} +
+ +
+ {[25, 50, 75].map((pct) => ( +
+ ))} + + onParamChange( + "confidence_threshold", + parseFloat(e.target.value), + ) + } + className="try-fair-confidence-slider disabled:cursor-wait w-full h-1.5 rounded-full appearance-none cursor-pointer outline-none" + style={{ + background: `linear-gradient(90deg, #0088FF 0%, #FF383C 100%)`, + }} + /> +
+ +
+
); diff --git a/frontend/src/features/try-fair/hooks/use-base-models.tsx b/frontend/src/features/try-fair/hooks/use-base-models.tsx index dbe9dc668..b8a24938c 100644 --- a/frontend/src/features/try-fair/hooks/use-base-models.tsx +++ b/frontend/src/features/try-fair/hooks/use-base-models.tsx @@ -33,13 +33,13 @@ export const getLocalModels = async ({ * (not mapped to TBaseModel) so the try-fair page can access inference fields * such as mlm:inference-endpoint, mlm:hyperparameters, etc. */ -export const useBaseModels = () => { +export const useStacBaseModels = () => { const { data, isLoading, error } = useQuery({ queryKey: ["fair-base-models"], queryFn: async () => { const res = await getBaseModels({ limit: 100 }); return (res.features as BaseModelStacItem[]).filter( - (f) => f.properties["fair:pinned"], + (f) => f.properties["fair:pinned"] && f.properties.deprecated === false, ); }, }); @@ -51,17 +51,18 @@ export const useBaseModels = () => { }; }; -export const useLocalModels = () => { +export const useStacLocalModels = () => { const { data, isLoading, error } = useQuery({ queryKey: ["fair-local-models"], queryFn: async () => { const res = await getLocalModels({ limit: 100 }); + return (res.features as BaseModelStacItem[]).filter( - (f) => f.properties["fair:pinned"], + // only return models that are pinned and not deprecated + (f) => f.properties["fair:pinned"] && f.properties.deprecated === false, ); }, }); - return { models: data ?? [], loading: isLoading, diff --git a/frontend/src/features/try-fair/hooks/use-fair-predict.tsx b/frontend/src/features/try-fair/hooks/use-fair-predict.tsx index 7aa410f02..ec4ed2de9 100644 --- a/frontend/src/features/try-fair/hooks/use-fair-predict.tsx +++ b/frontend/src/features/try-fair/hooks/use-fair-predict.tsx @@ -10,10 +10,10 @@ type PredictResult = { }; type PredictArgs = { - /** Base model — provides the inference endpoint URL */ + /** STAC item — provides the inference endpoint URL. */ model: BaseModelStacItem; - localModelUri: string; - tileServiceUrl: string; + modelUri: string; + imageUri: string; bbox: BBOX; /** Exact tile zoom used to build the bbox from draggable grid tile boundaries. */ gridZoom?: number; @@ -29,8 +29,8 @@ export const useFairPredict = () => { >({ mutationFn: async ({ model, - localModelUri, - tileServiceUrl, + modelUri, + imageUri, bbox, gridZoom, resolution, @@ -42,8 +42,8 @@ export const useFairPredict = () => { } const predictions = await runPredict(inferenceEndpoint, { - model_uri: localModelUri, - image_uri: tileServiceUrl, + model_uri: modelUri, + image_uri: imageUri, bbox, zoom: gridZoom ?? TRY_FAIR_RESOLUTION_ZOOM[resolution], params, diff --git a/frontend/src/features/try-fair/hooks/use-imagery-country.ts b/frontend/src/features/try-fair/hooks/use-imagery-country.ts new file mode 100644 index 000000000..30494dca9 --- /dev/null +++ b/frontend/src/features/try-fair/hooks/use-imagery-country.ts @@ -0,0 +1,31 @@ +import { useQuery } from "@tanstack/react-query"; +import { BBOX } from "@/types"; +import { + CountryResult, + reverseGeocodeCountry, +} from "@/features/try-fair/api/hot-imagery"; + +/** + * Reverse-geocodes an imagery's center (from its bounds) to a country via + * TanStack Query. Keyed on the center coordinate, so the Nominatim lookup is + * cached and de-duplicated across renders and picker re-opens; a location's + * country never changes, so it never goes stale. + */ +export const useImageryCountry = ( + bounds: BBOX | null | undefined, +): CountryResult | null => { + const center = bounds + ? [(bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2] + : null; + + const { data } = useQuery({ + queryKey: ["imagery-country", center?.[0], center?.[1]], + queryFn: ({ signal }) => + reverseGeocodeCountry(center![0], center![1], signal), + enabled: !!center, + staleTime: Infinity, + gcTime: Infinity, + }); + + return data ?? null; +}; diff --git a/frontend/src/features/try-fair/hooks/use-imagery-mapping-model.ts b/frontend/src/features/try-fair/hooks/use-imagery-mapping-model.ts new file mode 100644 index 000000000..b6d7be1ed --- /dev/null +++ b/frontend/src/features/try-fair/hooks/use-imagery-mapping-model.ts @@ -0,0 +1,83 @@ +import { ModelType } from "@/enums"; +import { + useGetAPIBaseModels, + useGetAPILocalModels, +} from "@/features/try-fair/api/features-to-map"; +import { + BaseModelStacItem, + getInferenceParams, +} from "@/features/try-fair/api/stac"; +import { useStartMappingStore } from "@/features/try-fair/utils/start-mapping-store"; +import { useMemo } from "react"; + +type UseImageryMappingModelOptions = { + feature: string; + confidence: number; + selectedModel: BaseModelStacItem | null; +}; + +/** + * Resolves the model used for a mapping run. + * + * Imagery mode uses the first compatible model returned by the category APIs; + * demo mode continues to use the model selected from the STAC catalogue. + */ +export const useImageryMappingModel = ({ + feature, + confidence, + selectedModel, +}: UseImageryMappingModelOptions) => { + const currentModelType = useStartMappingStore( + (state) => state.currentModelType, + ); + const isImageryMode = currentModelType === ModelType.IMAGERY; + const { data: apiBaseModels, isSuccess: hasLoadedApiBaseModels } = + useGetAPIBaseModels(feature, isImageryMode); + const { data: apiLocalModels, isSuccess: hasLoadedApiLocalModels } = + useGetAPILocalModels(feature, isImageryMode); + + const imageryModel = useMemo(() => { + const apiModels = [ + ...(apiBaseModels?.results ?? []), + ...(apiLocalModels?.results ?? []), + ]; + return apiModels[0]?.stac ?? null; + }, [apiBaseModels, apiLocalModels]); + const modelForMapping = isImageryMode ? imageryModel : selectedModel; + // Some category API responses embed STAC metadata without the top-level + // STAC `id`. `mlm:name` and the model asset URL are stable fallbacks used + // only for client-side grid and input-change tracking. + const mappingModelId = + modelForMapping?.id ?? + modelForMapping?.properties["mlm:name"] ?? + modelForMapping?.assets.model?.href; + const hasNoModelsForFeature = + isImageryMode && + hasLoadedApiBaseModels && + hasLoadedApiLocalModels && + imageryModel === null; + const inferenceParams = useMemo( + () => (modelForMapping ? getInferenceParams(modelForMapping) : []), + [modelForMapping], + ); + const paramValues = useMemo(() => { + const values: Record = { + confidence_threshold: confidence, + }; + inferenceParams.forEach(({ key, spec }) => { + if (key !== "confidence_threshold") { + values[key] = spec.default; + } + }); + return values; + }, [confidence, inferenceParams]); + + return { + modelForMapping, + mappingModelId, + modelUri: modelForMapping?.assets.model?.href, + hasNoModelsForFeature, + inferenceParams, + paramValues, + }; +}; diff --git a/frontend/src/features/try-fair/hooks/use-imagery-modal-map.ts b/frontend/src/features/try-fair/hooks/use-imagery-modal-map.ts new file mode 100644 index 000000000..2215e2b89 --- /dev/null +++ b/frontend/src/features/try-fair/hooks/use-imagery-modal-map.ts @@ -0,0 +1,53 @@ +import { useEffect, useRef, useState } from "react"; +import { Map as MapLibreMap } from "maplibre-gl"; +import { createImageryMap } from "@/features/try-fair/components/imagery/imagery-modal-map.layers"; + +/** + * Creates the MapLibre instance for the imagery/location dialog and returns it + * (once loaded) together with the container ref to hand to `MapComponent`. + * + * Deliberately NOT the app-wide `useMapInstance`: that hook starts TerraDraw and + * writes zoom into the global map store shared with the main try-fAIr map, so a + * second instance in the dialog would fight it. This one stays isolated. + * + * The dialog animates open, so the container can still be zero-width on the + * first run; MapLibre never fires `load` for a zero-size map, so we wait for a + * real size (ResizeObserver) before creating it, then keep it resized. + */ +export const useImageryModalMap = () => { + const mapContainerRef = useRef(null); + const [map, setMap] = useState(null); + + useEffect(() => { + const container = mapContainerRef.current; + if (!container) return; + + let instance: MapLibreMap | undefined; + let disposed = false; + + const createOnce = () => { + if (instance) return; + instance = createImageryMap(container); + instance.on("load", () => { + if (!disposed && instance) setMap(instance); + }); + }; + + const resizeObserver = new ResizeObserver(() => { + if (disposed || !container.clientWidth || !container.clientHeight) return; + if (instance) instance.resize(); + else createOnce(); + }); + resizeObserver.observe(container); + if (container.clientWidth && container.clientHeight) createOnce(); + + return () => { + disposed = true; + setMap(null); + resizeObserver.disconnect(); + instance?.remove(); + }; + }, []); + + return { map, mapContainerRef }; +}; diff --git a/frontend/src/features/try-fair/hooks/use-map-large-area.ts b/frontend/src/features/try-fair/hooks/use-map-large-area.ts new file mode 100644 index 000000000..6b8476dca --- /dev/null +++ b/frontend/src/features/try-fair/hooks/use-map-large-area.ts @@ -0,0 +1,500 @@ +import { MAX_TRAINING_AREA_SIZE, MIN_TRAINING_AREA_SIZE } from "@/config"; +import { DrawingModes } from "@/enums"; +import { useMapInstance } from "@/hooks/use-map-instance"; +import { BBOX, Feature } from "@/types"; +import { + featureIsWithinBounds, + formatAreaInAppropriateUnit, + getGeoJSONFeatureBounds, + showErrorToast, + showSuccessToast, + showWarningToast, + uuid4, + validateGeoJSONArea, +} from "@/utils"; +import { GeoJSONStoreFeatures } from "terra-draw"; +import { FeatureCollection, Polygon } from "geojson"; +import { GeoJSONSource } from "maplibre-gl"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTryFairParams } from "@/features/try-fair/hooks/use-try-fair-params"; +import { + MapLargeAreaRequest, + useSubmitMapLargeArea, +} from "@/features/try-fair/api/map-large-area"; +import { useStartMappingStore } from "@/features/try-fair/utils/start-mapping-store"; +import { TRY_FAIR_RESOLUTION_ZOOM } from "@/features/try-fair/utils/common"; + +export type AOITab = "whole" | "draw" | "upload"; + +const createFeatureFromBounds = (bounds: BBOX): Feature => { + return { + type: "Feature", + id: uuid4(), + properties: { + mode: DrawingModes.POLYGON, + }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [bounds[0], bounds[1]], + [bounds[2], bounds[1]], + [bounds[2], bounds[3]], + [bounds[0], bounds[3]], + [bounds[0], bounds[1]], + ], + ], + }, + }; +}; + +interface UseMapLargeAreaOptions { + imageryBounds?: BBOX | null; + onSubmit: () => void; + closeDialog: () => void; +} + +export const useMapLargeArea = ({ + imageryBounds, + onSubmit, + closeDialog, +}: UseMapLargeAreaOptions) => { + const { selectedImagery } = useStartMappingStore(); + const activeImageryBounds = imageryBounds ?? selectedImagery?.bounds ?? null; + const { mapContainerRef, map, drawingMode, setDrawingMode, terraDraw } = + useMapInstance( + undefined, + undefined, + "red", + activeImageryBounds ?? undefined, + ); + + const { mutate: submitMapLargeArea, isPending: isSubmittingMapLargeArea } = + useSubmitMapLargeArea(); + + const { modelId, selectedModel, inferenceParams, resolution, confidence } = + useTryFairParams(); + const [activeTab, setActiveTab] = useState("draw"); + const [selectedAOI, setSelectedAOI] = useState(null); + const [uploadedFileName, setUploadedFileName] = useState(null); + + const fileInputRef = useRef(null); + + const triggerFileSelect = () => { + if (fileInputRef.current) { + fileInputRef.current.value = ""; + fileInputRef.current.click(); + } + }; + + // Resize map & fit bounds initially + useEffect(() => { + if (!map || !activeImageryBounds) return; + map.resize(); + map.fitBounds( + [ + activeImageryBounds[0], + activeImageryBounds[1], + activeImageryBounds[2], + activeImageryBounds[3], + ], + { + padding: 40, + maxZoom: 18, + essential: true, + }, + ); + }, [map, activeImageryBounds]); + + // Render selected AOI directly on MapLibre style layer for guaranteed visual rendering + useEffect(() => { + if (!map) return; + + const SOURCE_ID = "large-area-aoi-source"; + const FILL_LAYER_ID = "large-area-aoi-fill"; + const OUTLINE_LAYER_ID = "large-area-aoi-outline"; + + const updateMapLayer = () => { + const geojsonData: FeatureCollection = selectedAOI + ? { + type: "FeatureCollection", + features: [selectedAOI as unknown as Feature], + } + : { type: "FeatureCollection", features: [] }; + + const source = map.getSource(SOURCE_ID) as GeoJSONSource | undefined; + + if (source) { + source.setData(geojsonData); + } else { + try { + map.addSource(SOURCE_ID, { + type: "geojson", + data: geojsonData, + }); + + if (!map.getLayer(FILL_LAYER_ID)) { + map.addLayer({ + id: FILL_LAYER_ID, + type: "fill", + source: SOURCE_ID, + paint: { + "fill-color": "#D73434", + "fill-opacity": 0.25, + }, + }); + } + + if (!map.getLayer(OUTLINE_LAYER_ID)) { + map.addLayer({ + id: OUTLINE_LAYER_ID, + type: "line", + source: SOURCE_ID, + paint: { + "line-color": "#D73434", + "line-width": 3, + }, + }); + } + } catch { + // Ignore source addition errors if map is unloading + } + } + }; + + if (map.isStyleLoaded()) { + updateMapLayer(); + } else { + map.once("styledata", updateMapLayer); + } + }, [map, selectedAOI]); + + const clearTerraDraw = useCallback(() => { + if (terraDraw) { + try { + const snapshot = terraDraw.getSnapshot(); + if (snapshot && snapshot.length > 0) { + const ids = snapshot + .map((f) => f.id) + .filter((id): id is string | number => id !== undefined); + if (ids.length > 0) { + terraDraw.removeFeatures(ids); + } + } + terraDraw.clear(); + } catch (err) { + console.error("Failed to clear TerraDraw:", err); + } + } + }, [terraDraw]); + + // Handle Tab Switch + const handleTabChange = useCallback( + (tab: AOITab) => { + setActiveTab(tab); + setSelectedAOI(null); + setUploadedFileName(null); + setDrawingMode(DrawingModes.STATIC); + clearTerraDraw(); + + if (tab === "whole") { + if (activeImageryBounds && activeImageryBounds.length === 4) { + const wholeFeature = createFeatureFromBounds(activeImageryBounds); + if (terraDraw) { + terraDraw.addFeatures([wholeFeature] as GeoJSONStoreFeatures[]); + } + setSelectedAOI(wholeFeature); + if (map) { + map.fitBounds( + [ + activeImageryBounds[0], + activeImageryBounds[1], + activeImageryBounds[2], + activeImageryBounds[3], + ], + { + padding: 40, + maxZoom: 18, + essential: true, + }, + ); + } + } + } else if (tab === "draw") { + setTimeout(() => { + clearTerraDraw(); + setDrawingMode(DrawingModes.POLYGON); + }, 50); + } else if (tab === "upload") { + triggerFileSelect(); + } + }, + [activeImageryBounds, clearTerraDraw, map, setDrawingMode, terraDraw], + ); + + // Set default mode on initial mount if tab is draw + useEffect(() => { + if (activeTab === "draw") { + setDrawingMode(DrawingModes.POLYGON); + } + }, [activeTab, setDrawingMode]); + + // TileJSON bounds can arrive after the user selects the whole-imagery tab. + // Create the AOI once those bounds become available. + useEffect(() => { + if (activeTab !== "whole" || !activeImageryBounds || selectedAOI) return; + + const wholeFeature = createFeatureFromBounds(activeImageryBounds); + if (terraDraw) { + terraDraw.addFeatures([wholeFeature] as GeoJSONStoreFeatures[]); + } + setSelectedAOI(wholeFeature); + }, [activeImageryBounds, activeTab, selectedAOI, terraDraw]); + + // TerraDraw finish listener + const handleDrawFinish = useCallback(() => { + if (!terraDraw) return; + const snapshot = terraDraw.getSnapshot() as Feature[]; + if (!snapshot || snapshot.length === 0) return; + + const latestFeature = snapshot[snapshot.length - 1]; + + // Match the size limits used by the offline prediction AOI upload flow. + if (validateGeoJSONArea(latestFeature)) { + showWarningToast( + `Area must be between ${formatAreaInAppropriateUnit( + MIN_TRAINING_AREA_SIZE, + )} and ${formatAreaInAppropriateUnit(MAX_TRAINING_AREA_SIZE)}.`, + ); + terraDraw.clear(); + setSelectedAOI(null); + setDrawingMode(DrawingModes.POLYGON); + return; + } + + if (activeImageryBounds && activeImageryBounds.length === 4) { + if (!featureIsWithinBounds(activeImageryBounds, latestFeature)) { + showWarningToast( + "The drawn polygon extends beyond the imagery bounds. Please draw within the imagery bounds.", + ); + terraDraw.clear(); + setSelectedAOI(null); + setDrawingMode(DrawingModes.POLYGON); + return; + } + } + + if (snapshot.length > 1) { + terraDraw.removeFeatures( + snapshot + .slice(0, snapshot.length - 1) + .map((f) => f.id) + .filter((id): id is string | number => id !== undefined), + ); + } + + setSelectedAOI(latestFeature); + setDrawingMode(DrawingModes.STATIC); + }, [activeImageryBounds, terraDraw, setDrawingMode]); + + useEffect(() => { + if (!terraDraw) return; + + const onFinish = () => { + handleDrawFinish(); + }; + + terraDraw.on("finish", onFinish); + + return () => { + terraDraw.off("finish", onFinish); + }; + }, [terraDraw, handleDrawFinish]); + + // Handle uploaded GeoJSON file directly via native file picker + const handleFileChange = async ( + event: React.ChangeEvent, + ) => { + const file = event.target.files?.[0]; + if (!file) return; + + try { + const text = await file.text(); + const parsed = JSON.parse(text); + + let polygonGeometry: Polygon | null = null; + let extractedFeature: Feature | null = null; + + if (parsed.type === "FeatureCollection") { + const firstFeature = parsed.features?.find( + (f: Feature) => + f.geometry?.type === "Polygon" || + f.geometry?.type === "MultiPolygon", + ); + if (firstFeature) { + extractedFeature = firstFeature; + polygonGeometry = firstFeature.geometry as Polygon; + } + } else if (parsed.type === "Feature") { + if ( + parsed.geometry?.type === "Polygon" || + parsed.geometry?.type === "MultiPolygon" + ) { + extractedFeature = parsed; + polygonGeometry = parsed.geometry as Polygon; + } + } else if (parsed.type === "Polygon" || parsed.type === "MultiPolygon") { + polygonGeometry = parsed as Polygon; + extractedFeature = { + type: "Feature", + id: uuid4(), + properties: { mode: DrawingModes.POLYGON }, + geometry: parsed, + }; + } + + if (!polygonGeometry || !extractedFeature) { + showErrorToast( + undefined, + `No valid Polygon feature found in ${file.name}.`, + ); + return; + } + + const uploadedFeature: Feature = { + type: "Feature", + id: extractedFeature.id || uuid4(), + properties: { + ...extractedFeature.properties, + mode: DrawingModes.POLYGON, + }, + geometry: polygonGeometry, + }; + + if (activeImageryBounds && activeImageryBounds.length === 4) { + if (!featureIsWithinBounds(activeImageryBounds, uploadedFeature)) { + showErrorToast( + undefined, + "The uploaded polygon is outside the imagery bounds. Please upload a polygon that lies within the imagery.", + ); + return; + } + } + + if (terraDraw) { + try { + const snapshot = terraDraw.getSnapshot(); + if (snapshot && snapshot.length > 0) { + const ids = snapshot + .map((f) => f.id) + .filter((id): id is string | number => id !== undefined); + if (ids.length > 0) terraDraw.removeFeatures(ids); + } + terraDraw.clear(); + } catch { + // ignore + } + terraDraw.addFeatures([uploadedFeature] as GeoJSONStoreFeatures[]); + } + + setUploadedFileName(file.name); + setSelectedAOI(uploadedFeature); + setDrawingMode(DrawingModes.STATIC); + + if (map) { + const bounds = getGeoJSONFeatureBounds(uploadedFeature); + map.fitBounds(bounds, { padding: 40, maxZoom: 18, essential: true }); + } + + showSuccessToast(`Loaded area of interest from ${file.name}.`); + } catch { + showErrorToast( + undefined, + `Failed to parse ${file.name}. Please select a valid GeoJSON file.`, + ); + } + }; + + const handleClearArea = (e?: React.MouseEvent) => { + if (e) { + e.stopPropagation(); + e.preventDefault(); + } + clearTerraDraw(); + setSelectedAOI(null); + setUploadedFileName(null); + setDrawingMode(DrawingModes.STATIC); + showSuccessToast("Selected area cleared."); + }; + + /** + * Called by the floating draw button in the modal. + * Clears any existing drawing and re-enables polygon drawing mode. + */ + const handleEnableDrawing = useCallback(() => { + clearTerraDraw(); + setSelectedAOI(null); + setTimeout(() => { + setDrawingMode(DrawingModes.POLYGON); + }, 50); + }, [clearTerraDraw, setDrawingMode]); + + const handleSubmit = () => { + if (!selectedAOI) return; + + // Convert resolution to numeric zoom level + const zoomNumber = TRY_FAIR_RESOLUTION_ZOOM[resolution] ?? 18; + + // Extra dynamic inference parameters from STAC + const extraParams: Record = {}; + if (inferenceParams && inferenceParams.length > 0) { + inferenceParams.forEach((param) => { + if (param.key !== "confidence_threshold") { + extraParams[param.key] = param.value; + } + }); + } + + const payload: MapLargeAreaRequest = { + model_stac_id: selectedModel?.id ?? modelId, + image_uri: selectedImagery?.tileUrl ?? "", + zoom: zoomNumber, + params: { + confidence_threshold: confidence, + ...extraParams, + }, + ...(activeTab === "whole" + ? { bbox: getGeoJSONFeatureBounds(selectedAOI) } + : { geom: selectedAOI.geometry }), + }; + + submitMapLargeArea(payload, { + onSuccess: () => { + showSuccessToast("Map large area request submitted successfully."); + onSubmit(); + closeDialog(); + }, + onError: (err) => { + showErrorToast(err, "Failed to submit map large area request."); + }, + }); + }; + + return { + mapContainerRef, + map, + drawingMode, + setDrawingMode, + terraDraw, + activeTab, + selectedAOI, + uploadedFileName, + fileInputRef, + isSubmittingMapLargeArea, + handleTabChange, + handleFileChange, + handleClearArea, + handleEnableDrawing, + handleSubmit, + }; +}; diff --git a/frontend/src/features/try-fair/hooks/use-modal-map.tsx b/frontend/src/features/try-fair/hooks/use-modal-map.tsx new file mode 100644 index 000000000..c6d4483f7 --- /dev/null +++ b/frontend/src/features/try-fair/hooks/use-modal-map.tsx @@ -0,0 +1,50 @@ +import maplibregl, { Map } from "maplibre-gl"; +import { useEffect, useRef, useState } from "react"; +import { BASEMAPS } from "@/enums"; +import { MAP_STYLES, MAX_ZOOM_LEVEL } from "@/config"; + +/** + * A lightweight MapLibre instance for maps rendered inside dialogs — no + * TerraDraw, no global zoom store, no URL hash syncing. The map is created + * when the container mounts and destroyed when it unmounts, so it plays well + * with dialogs that mount/unmount their content. + */ +export const useModalMap = ({ + center = [0, 0], + zoom = 1, +}: { + center?: [number, number]; + zoom?: number; +} = {}) => { + const mapContainerRef = useRef(null); + const [map, setMap] = useState(null); + + useEffect(() => { + if (!mapContainerRef.current) return; + + const mapInstance = new maplibregl.Map({ + container: mapContainerRef.current, + style: MAP_STYLES[BASEMAPS.OSM], + center, + zoom, + minZoom: 1, + maxZoom: MAX_ZOOM_LEVEL, + pitchWithRotate: false, + attributionControl: false, + }); + + mapInstance.on("load", () => { + setMap(mapInstance); + // Dialog animations can resize the container after mount. + mapInstance.resize(); + }); + + return () => { + setMap(null); + mapInstance.remove(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mapContainerRef]); + + return { map, mapContainerRef }; +}; diff --git a/frontend/src/features/try-fair/hooks/use-oam-item.ts b/frontend/src/features/try-fair/hooks/use-oam-item.ts new file mode 100644 index 000000000..4b5b85df7 --- /dev/null +++ b/frontend/src/features/try-fair/hooks/use-oam-item.ts @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; +import axios from "axios"; +import { HOT_IMAGERY_COLLECTION_ID, HOT_IMAGERY_STAC_API_URL } from "@/config"; +import { OAMImageryItem } from "@/features/try-fair/api/hot-imagery"; + +type StacItemRaw = { + id: string; + bbox: [number, number, number, number]; + geometry: GeoJSON.Geometry; + assets: Record; + properties: { + title?: string; + gsd?: number; + license?: string; + created?: string; + end_datetime?: string; + providers?: Array<{ name: string }>; + "oam:platform_type"?: string; + "oam:producer_name"?: string; + }; +}; + +const toOAMItem = (raw: StacItemRaw): OAMImageryItem => { + const assetName = raw.assets.visual + ? "visual" + : (Object.keys(raw.assets)[0] ?? "visual"); + return { + id: raw.id, + bbox: raw.bbox, + geometry: raw.geometry, + title: raw.properties.title ?? "Untitled Image", + provider: + raw.properties["oam:producer_name"] ?? + raw.properties.providers?.[0]?.name ?? + "Unknown", + gsd: raw.properties.gsd ?? null, + acquiredAt: raw.properties.end_datetime ?? raw.properties.created ?? null, + license: raw.properties.license ?? null, + platform: raw.properties["oam:platform_type"] ?? null, + thumbnailUrl: raw.assets.thumbnail?.href ?? null, + assetName, + }; +}; + +/** + * Fetches a single OpenAerialMap STAC item by its ID. + * Used to hydrate the imagery selection from a shared URL on page load. + * + * Returns `null` while loading or if the item cannot be found. + */ +export const useOAMItem = ( + itemId: string | null, +): { item: OAMImageryItem | null; loading: boolean; error: boolean } => { + const [item, setItem] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + + useEffect(() => { + if (!itemId) { + setItem(null); + setError(false); + return; + } + + const controller = new AbortController(); + setLoading(true); + setError(false); + + axios + .get( + `${HOT_IMAGERY_STAC_API_URL}/collections/${HOT_IMAGERY_COLLECTION_ID}/items/${encodeURIComponent(itemId)}`, + { signal: controller.signal }, + ) + .then(({ data }) => { + if (!controller.signal.aborted) { + setItem(toOAMItem(data)); + } + }) + .catch(() => { + if (!controller.signal.aborted) { + setError(true); + setItem(null); + } + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + + return () => controller.abort(); + }, [itemId]); + + return { item, loading, error }; +}; diff --git a/frontend/src/features/try-fair/hooks/use-try-fair-imagery.ts b/frontend/src/features/try-fair/hooks/use-try-fair-imagery.ts new file mode 100644 index 000000000..f0fbfc0d6 --- /dev/null +++ b/frontend/src/features/try-fair/hooks/use-try-fair-imagery.ts @@ -0,0 +1,211 @@ +import { ImagerySource, ModelType, TileServiceType } from "@/enums"; +import { BaseModelStacItem } from "@/features/try-fair/api/stac"; +import { getImageryTileUrl } from "@/features/try-fair/api/hot-imagery"; +import { useOAMItem } from "@/features/try-fair/hooks/use-oam-item"; +import { useStartMappingStore } from "@/features/try-fair/utils/start-mapping-store"; +import { + DEFAULT_FAIR_IMAGERY_CENTER, + FALLBACK_FAIR_IMAGERY, + FALLBACK_FAIR_IMAGERY_CENTER, + TRY_FAIR_INITIAL_MAP_ZOOM, +} from "@/features/try-fair/utils/common"; +import { useTileservice } from "@/hooks/use-tileservice"; +import { BBOX } from "@/types"; +import { getTileServerRegex, getTileServerTypeFromURL } from "@/utils"; +import { Map } from "maplibre-gl"; +import { useEffect, useMemo, useRef } from "react"; +import { useShallow } from "zustand/react/shallow"; + +type UseTryFairImageryOptions = { + map: Map | null; + selectedModel: BaseModelStacItem | null; + mode: ModelType; + imageryUrl: string | null; + imageryTileServiceType: TileServiceType | null; + oamItemId: string | null; +}; + +/** + * Resolves the active imagery, restores it from shared URLs, and keeps the + * map camera aligned to imagery that has known bounds. + */ +export const useTryFairImagery = ({ + map, + selectedModel, + mode, + imageryUrl, + imageryTileServiceType, + oamItemId, +}: UseTryFairImageryOptions) => { + const { + currentModelType, + setCurrentModelType, + selectedImagery, + setSeletedImagery, + } = useStartMappingStore( + useShallow((state) => ({ + currentModelType: state.currentModelType, + setCurrentModelType: state.setCurrentModelType, + selectedImagery: state.selectedImagery, + setSeletedImagery: state.setSeletedImagery, + })), + ); + const { item: sharedOAMItem } = useOAMItem(oamItemId); + + const tileServiceUrl = useMemo(() => { + const modelImagery = + currentModelType === ModelType.DEMO + ? selectedModel?.properties["fair:source_imagery"] + : selectedImagery?.tileUrl; + if (!modelImagery) return FALLBACK_FAIR_IMAGERY; + const regex = getTileServerRegex(getTileServerTypeFromURL(modelImagery)); + return regex.test(modelImagery) ? modelImagery : FALLBACK_FAIR_IMAGERY; + }, [currentModelType, selectedImagery, selectedModel]); + + const tileServiceType = + currentModelType === ModelType.IMAGERY && + selectedImagery?.source === ImagerySource.CUSTOM + ? selectedImagery.tileServiceType + : (imageryTileServiceType ?? getTileServerTypeFromURL(tileServiceUrl)); + + const { + tileserverURL, + setTileserverURL, + setTileServiceType, + loading: tileLoading, + tileJSONMetadata, + tileServiceTypeValidity, + } = useTileservice(tileServiceType, tileServiceUrl); + + useEffect(() => { + setTileserverURL(tileServiceUrl); + setTileServiceType(tileServiceType); + }, [setTileServiceType, setTileserverURL, tileServiceType, tileServiceUrl]); + + // Recreate selected imagery from a shared URL. OAM supplies its bounds and + // compatible TileJSON sources supply their metadata after loading. + useEffect(() => { + if (mode === ModelType.DEMO) { + setCurrentModelType(ModelType.DEMO); + return; + } + + if (oamItemId) { + if (!sharedOAMItem) return; + setCurrentModelType(ModelType.IMAGERY); + setSeletedImagery({ + source: ImagerySource.OPEN_AERIAL_MAP, + item: sharedOAMItem, + tileUrl: getImageryTileUrl(sharedOAMItem.id, sharedOAMItem.assetName), + bounds: sharedOAMItem.bbox, + }); + return; + } + + if (imageryUrl) { + setCurrentModelType(ModelType.IMAGERY); + setSeletedImagery({ + source: ImagerySource.CUSTOM, + tileUrl: imageryUrl, + tileServiceType: + imageryTileServiceType ?? getTileServerTypeFromURL(imageryUrl), + bounds: null, + }); + } + }, [ + imageryTileServiceType, + imageryUrl, + mode, + oamItemId, + setCurrentModelType, + setSeletedImagery, + sharedOAMItem, + ]); + + const imageryCenter = useMemo((): [number, number] => { + if (currentModelType === ModelType.IMAGERY && selectedImagery?.bounds) { + const [w, s, e, n] = selectedImagery.bounds; + return [(w + e) / 2, (s + n) / 2]; + } + if (currentModelType === ModelType.IMAGERY && tileJSONMetadata?.center) { + return [tileJSONMetadata.center[0], tileJSONMetadata.center[1]]; + } + if (currentModelType === ModelType.IMAGERY && tileJSONMetadata?.bounds) { + const [w, s, e, n] = tileJSONMetadata.bounds as BBOX; + return [(w + e) / 2, (s + n) / 2]; + } + const previewLocation = selectedModel?.properties["fair:preview_location"]; + if (previewLocation) return previewLocation.coordinates; + + return tileServiceUrl === FALLBACK_FAIR_IMAGERY + ? FALLBACK_FAIR_IMAGERY_CENTER + : DEFAULT_FAIR_IMAGERY_CENTER; + }, [ + currentModelType, + selectedImagery, + selectedModel, + tileJSONMetadata, + tileServiceUrl, + ]); + + // TMS templates do not provide a reliable imagery extent, so preserve the + // user's current view both on selection and on a shared-link initial load. + const isCustomTMSImagery = + (currentModelType === ModelType.IMAGERY && + selectedImagery?.source === ImagerySource.CUSTOM && + selectedImagery.tileServiceType === TileServiceType.TMS) || + (mode === ModelType.IMAGERY && + Boolean(imageryUrl) && + imageryTileServiceType === TileServiceType.TMS); + + const imageryBounds = useMemo(() => { + if (currentModelType === ModelType.IMAGERY && selectedImagery?.bounds) { + return selectedImagery.bounds; + } + if (tileJSONMetadata?.bounds) return tileJSONMetadata.bounds as BBOX; + return null; + }, [currentModelType, selectedImagery, tileJSONMetadata]); + + const mapFlownRef = useRef(false); + useEffect(() => { + if (!map || !selectedModel || isCustomTMSImagery) return; + + const flyToImagery = () => { + mapFlownRef.current = true; + map.flyTo({ + center: imageryCenter, + zoom: TRY_FAIR_INITIAL_MAP_ZOOM, + essential: true, + }); + }; + + if (mapFlownRef.current || map.isStyleLoaded()) { + flyToImagery(); + } else { + map.once("load", flyToImagery); + return () => { + map.off("load", flyToImagery); + }; + } + }, [imageryCenter, isCustomTMSImagery, map, selectedModel]); + + useEffect(() => { + if (!map || !imageryBounds || isCustomTMSImagery) return; + map.fitBounds( + [imageryBounds[0], imageryBounds[1], imageryBounds[2], imageryBounds[3]], + { padding: 40, duration: 0, essential: true }, + ); + }, [imageryBounds, isCustomTMSImagery, map]); + + return { + currentModelType, + imageryBounds, + imageryCenter: isCustomTMSImagery ? undefined : imageryCenter, + selectedImagery, + setCurrentModelType, + setSeletedImagery, + tileLoading, + tileServiceTypeValidity, + tileserverURL, + }; +}; diff --git a/frontend/src/features/try-fair/hooks/use-try-fair-params.tsx b/frontend/src/features/try-fair/hooks/use-try-fair-params.tsx index 2c535e8cd..1a6aa04c4 100644 --- a/frontend/src/features/try-fair/hooks/use-try-fair-params.tsx +++ b/frontend/src/features/try-fair/hooks/use-try-fair-params.tsx @@ -1,9 +1,28 @@ -import { TryFairMapOutputType, TryFairResolution } from "@/enums/try-fair"; +import { + ModelType, + TileServiceType, + TryFairMapOutputType, + TryFairResolution, +} from "@/enums"; import { parseAsFloat, parseAsString, useQueryStates } from "nuqs"; +import { useStacBaseModels, useStacLocalModels } from "./use-base-models"; +import { useMemo } from "react"; +import { getSelectedModel } from "@/features/try-fair/utils/models"; +import { getInferenceParams } from "@/features/try-fair/api/stac"; const VALID_OUTPUTS = Object.values(TryFairMapOutputType) as string[]; const VALID_RESOLUTIONS = Object.values(TryFairResolution) as string[]; +/** Default values for all Try fAIr parameters. */ +export const TRY_FAIR_PARAM_DEFAULTS = { + model: "dinov3s-buildings", + output: TryFairMapOutputType.POLYGON, + resolution: TryFairResolution.LOW, + confidence: 0.7, + feature: "buildings", + mode: ModelType.DEMO, +} as const; + /** * Persists the Try fAIr sidebar UI state in URL search params via nuqs. * @@ -12,18 +31,56 @@ const VALID_RESOLUTIONS = Object.values(TryFairResolution) as string[]; * output — visualization type: "polygon" | "points" | "cluster" * resolution — zoom resolution: "low" | "mid" | "high" * confidence — confidence threshold (0–1), e.g. 0.5 + * feature — selected feature category when mapping user-supplied imagery + * mode — whether model demo imagery or user-selected imagery is active + * imagery — custom imagery tile URL (never a bbox) + * imageryType — selected custom imagery service type + * oamItem — OpenAerialMap item ID; its URL and bbox are hydrated on load */ export const useTryFairParams = () => { const [params, setParams] = useQueryStates( { - model: parseAsString.withDefault("dinov3s-buildings"), - output: parseAsString.withDefault(TryFairMapOutputType.POLYGON), - resolution: parseAsString.withDefault(TryFairResolution.MID), - confidence: parseAsFloat.withDefault(0.7), + model: parseAsString.withDefault(TRY_FAIR_PARAM_DEFAULTS.model), + output: parseAsString.withDefault(TRY_FAIR_PARAM_DEFAULTS.output), + resolution: parseAsString.withDefault(TRY_FAIR_PARAM_DEFAULTS.resolution), + confidence: parseAsFloat, + feature: parseAsString.withDefault(TRY_FAIR_PARAM_DEFAULTS.feature), + mode: parseAsString.withDefault(TRY_FAIR_PARAM_DEFAULTS.mode), + imagery: parseAsString, + imageryType: parseAsString, + oamItem: parseAsString, }, { history: "replace" }, ); + const { models: allModels } = useStacBaseModels(); + const { models: localModels } = useStacLocalModels(); + + const models = useMemo( + () => [...allModels, ...localModels], + [allModels, localModels], + ); + + const selectedModel = useMemo( + () => getSelectedModel(models, params.model), + [models, params.model], + ); + + const inferenceParams = useMemo( + () => (selectedModel ? getInferenceParams(selectedModel) : []), + [selectedModel], + ); + + const defaultConfidence = useMemo(() => { + const confidenceParam = inferenceParams.find( + (param) => param.key === "confidence_threshold", + ); + if (confidenceParam && typeof confidenceParam.spec.default === "number") { + return confidenceParam.spec.default; + } + return TRY_FAIR_PARAM_DEFAULTS.confidence; + }, [inferenceParams]); + const outputType = VALID_OUTPUTS.includes(params.output) ? (params.output as TryFairMapOutputType) : TryFairMapOutputType.POINTS; @@ -32,15 +89,62 @@ export const useTryFairParams = () => { ? (params.resolution as TryFairResolution) : TryFairResolution.LOW; + const confidence = params.confidence ?? defaultConfidence; + + const mode = + params.mode === ModelType.IMAGERY ? ModelType.IMAGERY : ModelType.DEMO; + + const imageryTileServiceType = Object.values(TileServiceType).includes( + params.imageryType as TileServiceType, + ) + ? (params.imageryType as TileServiceType) + : null; + + const isParametersDefault = + resolution === TRY_FAIR_PARAM_DEFAULTS.resolution && + (params.confidence === null || params.confidence === defaultConfidence); + + const resetParameters = () => + setParams({ + resolution: TRY_FAIR_PARAM_DEFAULTS.resolution, + confidence: defaultConfidence, + }); + return { modelId: params.model, + selectedModel, + inferenceParams, outputType, resolution, - confidence: params.confidence, + confidence, + feature: params.feature, + mode, + imageryUrl: params.imagery, + imageryTileServiceType, + oamItemId: params.oamItem, setModelId: (id: string) => setParams({ model: id }), setOutputType: (type: TryFairMapOutputType) => setParams({ output: type }), setResolution: (res: TryFairResolution) => setParams({ resolution: res }), - setConfidence: (val: number) => setParams({ confidence: val }), + setConfidence: (val: number | null) => setParams({ confidence: val }), + setFeature: (feature: string) => setParams({ feature }), + setMode: (mode: ModelType) => setParams({ mode }), + setImagery: ({ + url, + tileServiceType, + oamItemId, + }: { + url: string | null; + tileServiceType: TileServiceType | null; + oamItemId: string | null; + }) => + setParams({ + imagery: url, + imageryType: tileServiceType, + oamItem: oamItemId, + }), + + isParametersDefault, + resetParameters, }; }; diff --git a/frontend/src/features/try-fair/hooks/use-try-fair-tour.ts b/frontend/src/features/try-fair/hooks/use-try-fair-tour.ts new file mode 100644 index 000000000..74dd9916a --- /dev/null +++ b/frontend/src/features/try-fair/hooks/use-try-fair-tour.ts @@ -0,0 +1,77 @@ +import { + getTryFairGuidedTourSteps, + getTryFairStartMappingStep, +} from "@/constants/site-tour"; +import { TRY_FAIR_TOUR_START_MAPPING_BUTTON_SEEN_LOCAL_STORAGE_KEY } from "@/config"; +import { useLocalStorage } from "@/hooks/use-storage"; +import { useTour, type StepType } from "@reactour/tour"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +/** Controls the guided Try fAIr tour and the one-time Map-button prompt. */ +export const useTryFairTour = (isSmallViewport: boolean) => { + const { getValue, setValue } = useLocalStorage(); + const { setIsOpen, setCurrentStep, setSteps } = useTour(); + const [mapClickCount, setMapClickCount] = useState(0); + const [hasSeenStartMappingStep, setHasSeenStartMappingStep] = useState( + () => + getValue(TRY_FAIR_TOUR_START_MAPPING_BUTTON_SEEN_LOCAL_STORAGE_KEY) === + "true", + ); + + const guidedTourSteps = useMemo( + () => getTryFairGuidedTourSteps(isSmallViewport), + [isSmallViewport], + ); + const startMappingStep = useMemo( + () => getTryFairStartMappingStep(), + [], + ); + + const openGuidedTour = useCallback(() => { + const firstSelector = guidedTourSteps[0]?.selector; + if ( + typeof firstSelector === "string" && + !document.querySelector(firstSelector) + ) + return; + + setSteps?.(guidedTourSteps); + setCurrentStep(0); + setIsOpen(true); + }, [guidedTourSteps, setCurrentStep, setIsOpen, setSteps]); + + const openStartMappingStep = useCallback(() => { + const selector = startMappingStep.selector; + if (typeof selector === "string" && !document.querySelector(selector)) + return; + + setSteps?.([startMappingStep]); + setCurrentStep(0); + setIsOpen(true); + }, [setCurrentStep, setIsOpen, setSteps, startMappingStep]); + + const recordMapRun = useCallback(() => { + setMapClickCount((count) => count + 1); + }, []); + + const closeGuidedTour = useCallback(() => { + setIsOpen(false); + }, [setIsOpen]); + + useEffect(() => { + if (mapClickCount !== 4 || isSmallViewport || hasSeenStartMappingStep) + return; + + openStartMappingStep(); + setValue(TRY_FAIR_TOUR_START_MAPPING_BUTTON_SEEN_LOCAL_STORAGE_KEY, "true"); + setHasSeenStartMappingStep(true); + }, [ + hasSeenStartMappingStep, + isSmallViewport, + mapClickCount, + openStartMappingStep, + setValue, + ]); + + return { closeGuidedTour, openGuidedTour, recordMapRun }; +}; diff --git a/frontend/src/features/try-fair/types/imagery-types.ts b/frontend/src/features/try-fair/types/imagery-types.ts new file mode 100644 index 000000000..06d1d6aa2 --- /dev/null +++ b/frontend/src/features/try-fair/types/imagery-types.ts @@ -0,0 +1,43 @@ +import { TileServiceType, ImagerySource } from "@/enums"; +import { BBOX } from "@/types"; + +/** A lean, UI-friendly view of an OpenAerialMap STAC imagery item. */ +export type OAMImageryItem = { + id: string; + bbox: BBOX; + geometry: GeoJSON.Geometry; + title: string; + provider: string; + /** Ground sample distance in meters, e.g. 0.02 for 2 cm. */ + gsd: number | null; + /** Acquisition date (ISO string). */ + acquiredAt: string | null; + license: string | null; + platform: string | null; + thumbnailUrl: string | null; + /** Name of the renderable asset on the raster API (usually "visual"). */ + assetName: string; +}; + +export type AppliedCustomImagery = { + tileUrl: string; + tileServiceType: TileServiceType; + bounds: BBOX | null; +}; +/** The imagery choice emitted by the imagery/location dialog on Apply. */ +export type ImagerySelection = + | { + source: ImagerySource.OPEN_AERIAL_MAP; + item: OAMImageryItem; + /** Titiler XYZ tile URL template for the selected item. */ + tileUrl: string; + bounds: BBOX; + } + | { + source: ImagerySource.CUSTOM; + tileUrl: string; + tileServiceType: TileServiceType; + bounds: BBOX | null; + }; +export type DatePreset = "" | "week" | "month" | "year"; +export type ResolutionPreset = "" | "lt05" | "05to2" | "2to10" | "gt10"; diff --git a/frontend/src/features/try-fair/utils/common.tsx b/frontend/src/features/try-fair/utils/common.tsx index edf80403b..f278864f9 100644 --- a/frontend/src/features/try-fair/utils/common.tsx +++ b/frontend/src/features/try-fair/utils/common.tsx @@ -6,6 +6,14 @@ import { ClusterIcon } from "@/components/ui/icons/cluster-icon"; import { PolygonIcon } from "@/components/ui/icons/polygon-icon"; import React from "react"; import { TRY_FAIR_GRID_SIZE } from "@/config"; +import { ImagerySource } from "@/enums"; +import { + DatePreset, + ResolutionPreset, +} from "@/features/try-fair/types/imagery-types"; +import { BuildingIcon } from "@/components/ui/icons/buildings-icon"; +import { SolarPanelIcon } from "@/components/ui/icons/solar-panel-icon"; +import { TreesIcon } from "@/components/ui/icons/trees-icon"; // This is the default zoom level to start mapping. @@ -64,9 +72,9 @@ export const OUTPUT_TYPES: { ]; export const TRY_FAIR_RESOLUTION_ZOOM: Record = { - [TryFairResolution.LOW]: 18, + [TryFairResolution.LOW]: 20, [TryFairResolution.MID]: 19, - [TryFairResolution.HIGH]: 20, + [TryFairResolution.HIGH]: 18, }; /** @@ -118,3 +126,91 @@ export const DEFAULT_SELECTED_GRID: SelectedGridSpec = { /** The grid footprint is a constant size, independent of zoom/resolution. */ export const getGridSpec = (): SelectedGridSpec => DEFAULT_SELECTED_GRID; + +/** + * Maps a numeric, string, or boolean confidence threshold value to a discrete accuracy label. + * + * @param value - The raw parameter value representing the confidence threshold (typically between 0 and 1). + * @returns A discrete accuracy label string: "Low", "Medium", or "High" (or empty string if invalid). + */ +export const getAccuracyLabel = (value: number | string | boolean): string => { + const percentage = Math.round(Number(value) * 100); + if (isNaN(percentage)) return ""; + if (percentage <= 25) { + return "Low"; + } + if (percentage <= 50) { + return "Medium"; + } + return "High"; +}; + +export const IMAGERY_SOURCES: { value: ImagerySource; label: string }[] = [ + { value: ImagerySource.OPEN_AERIAL_MAP, label: "OpenAerialMap" }, + { value: ImagerySource.CUSTOM, label: "Custom Imagery" }, +]; + +export const IMAGERY_DATE_OPTIONS: { label: string; value: DatePreset }[] = [ + { label: "Any date", value: "" }, + { label: "Past week", value: "week" }, + { label: "Past month", value: "month" }, + { label: "Past year", value: "year" }, +]; + +export const IMAGERY_RESOLUTION_PRESETS: { + label: string; + value: ResolutionPreset; +}[] = [ + { label: "Any resolution", value: "" }, + { label: "< 0.5 m", value: "lt05" }, + { label: "0.5 – 2 m", value: "05to2" }, + { label: "2 – 10 m", value: "2to10" }, + { label: "> 10 m", value: "gt10" }, +]; + +export const DAY_MS = 86_400_000; + +export const withinDate = (iso: string | null, preset: DatePreset): boolean => { + if (!preset) return true; + if (!iso) return false; + const days = preset === "week" ? 7 : preset === "month" ? 30 : 365; + return new Date(iso).getTime() >= Date.now() - days * DAY_MS; +}; + +export const withinResolution = ( + gsd: number | null, + preset: ResolutionPreset, +): boolean => { + if (!preset) return true; + if (gsd == null) return false; + if (preset === "lt05") return gsd < 0.5; + if (preset === "05to2") return gsd >= 0.5 && gsd <= 2; + if (preset === "2to10") return gsd > 2 && gsd <= 10; + return gsd > 10; +}; + +export const FEATURES_TO_MAP = [ + { + label: "Buildings", + Icon: BuildingIcon, + value: "buildings", + }, + { + label: "Solar Panels", + Icon: SolarPanelIcon, + value: "solar-panel", + }, + { + label: "Trees", + Icon: TreesIcon, + value: "trees", + }, +]; + +/** ISO 3166-1 alpha-2 code → flag emoji (regional indicator symbols). */ +export const flagEmoji = (code: string): string => + code.length === 2 + ? String.fromCodePoint( + ...[...code.toUpperCase()].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65), + ) + : "🏳️"; diff --git a/frontend/src/features/try-fair/utils/start-mapping-store.ts b/frontend/src/features/try-fair/utils/start-mapping-store.ts new file mode 100644 index 000000000..24574e2a9 --- /dev/null +++ b/frontend/src/features/try-fair/utils/start-mapping-store.ts @@ -0,0 +1,55 @@ +import { ModelType, TryFairMapOutputType } from "@/enums"; +import { ImagerySelection } from "@/features/try-fair/types/imagery-types"; +import { BBOX } from "@/types"; +import { create } from "zustand"; + +type IStartMappingStore = { + selectedImagery: ImagerySelection | null; + setSeletedImagery: (imagery: ImagerySelection | null) => void; + + downloadType: string; + setDownloadType: (imagery: string) => void; + showChooseLocationModal: boolean; + setShowChooseLocationModal: (show: boolean) => void; + showSigninModal: boolean; + setShowSigninModal: (show: boolean) => void; + showShareModal: boolean; + setShowShareModal: (show: boolean) => void; + currentModelType: ModelType; + setCurrentModelType: (type: ModelType) => void; + + /** Current prediction results — synced from the try-fair page. */ + predictions: GeoJSON.FeatureCollection | null; + setPredictions: (predictions: GeoJSON.FeatureCollection | null) => void; + predictionBBox: BBOX | null; + setPredictionBBox: (bbox: BBOX | null) => void; + predictionGridZoom: number | null; + setPredictionGridZoom: (zoom: number | null) => void; + outputType: TryFairMapOutputType; + setOutputType: (type: TryFairMapOutputType) => void; +}; + +export const useStartMappingStore = create((set) => ({ + selectedImagery: null, + setSeletedImagery: (selectedImagery) => set({ selectedImagery }), + downloadType: "", + setDownloadType: (downloadType) => set({ downloadType }), + showChooseLocationModal: false, + setShowChooseLocationModal: (showChooseLocationModal) => + set({ showChooseLocationModal }), + showSigninModal: false, + setShowSigninModal: (showSigninModal) => set({ showSigninModal }), + showShareModal: false, + setShowShareModal: (showShareModal) => set({ showShareModal }), + currentModelType: ModelType.DEMO, + setCurrentModelType: (currentModelType) => set({ currentModelType }), + + predictions: null, + setPredictions: (predictions) => set({ predictions }), + predictionBBox: null, + setPredictionBBox: (predictionBBox) => set({ predictionBBox }), + predictionGridZoom: null, + setPredictionGridZoom: (predictionGridZoom) => set({ predictionGridZoom }), + outputType: TryFairMapOutputType.POLYGON, + setOutputType: (outputType) => set({ outputType }), +})); diff --git a/frontend/src/features/user-profile/components/offline-predictions/publish-prediction-flow.tsx b/frontend/src/features/user-profile/components/offline-predictions/publish-prediction-flow.tsx index 7e92d80de..cdaa774b6 100644 --- a/frontend/src/features/user-profile/components/offline-predictions/publish-prediction-flow.tsx +++ b/frontend/src/features/user-profile/components/offline-predictions/publish-prediction-flow.tsx @@ -52,6 +52,7 @@ export const PublishPredictionFlow = ({ onClose={handleClose} onConfirm={handleConfirm} isConfirming={isPending} + rounded message={ isPublished ? "Confirm you want to Retract this prediction from public view" diff --git a/frontend/src/hooks/use-map-instance.ts b/frontend/src/hooks/use-map-instance.ts index ddfad8c65..6c00671f7 100644 --- a/frontend/src/hooks/use-map-instance.ts +++ b/frontend/src/hooks/use-map-instance.ts @@ -1,19 +1,28 @@ import { DrawingModes } from "@/enums"; import { Map } from "maplibre-gl"; import { setupMaplibreMap } from "@/components/map/setups/setup-maplibre"; -import { setupTerraDraw } from "@/components/map/setups/setup-terra-draw"; +import { + setupTerraDraw, + TerraDrawStyleVariant, +} from "@/components/map/setups/setup-terra-draw"; import { useEffect, useMemo, useRef, useState } from "react"; import { useMapStore } from "@/store/map-store"; +import { BBOX } from "@/types"; /** * useMapInstance - Initializes and manages a MapLibre map instance with TerraDraw integration. * * @param {boolean} pmtiles - Optional flag to enable PMTiles support. + * @param {boolean} hash - Optional flag to enable URL location hash. + * @param {TerraDrawStyleVariant} styleVariant - Optional drawing style variant ("default" | "red"). Defaults to "red". + * @param {BBOX | null} imageryBounds - Optional imagery bounding box used to constrain polygon drawing to the imagery extent. * @returns {Object} - Contains map instance, zoom level, drawing mode, and container ref. */ export const useMapInstance = ( pmtiles: boolean = false, hash: boolean = false, + styleVariant: TerraDrawStyleVariant = "default", + imageryBounds?: BBOX | null, ) => { const mapContainerRef = useRef(null); const [map, setMap] = useState(null); @@ -38,11 +47,11 @@ export const useMapInstance = ( const terraDraw = useMemo(() => { if (map) { - const draw = setupTerraDraw(map); + const draw = setupTerraDraw(map, styleVariant, imageryBounds); draw.start(); return draw; } - }, [map]); + }, [map, styleVariant, imageryBounds]); // Sync the drawing modes between terraDraw // and the application state diff --git a/frontend/src/services/api-routes.ts b/frontend/src/services/api-routes.ts index 6e9b72154..080498e0b 100644 --- a/frontend/src/services/api-routes.ts +++ b/frontend/src/services/api-routes.ts @@ -130,4 +130,15 @@ export const API_ENDPOINTS = { `/collections/local-models/items/?limit=${limit}`, GET_BASE_MODEL_BY_ID: (id: string): string => `/collections/base-models/items/${id}`, + + GET_CATEGORIES: "/categories/", + GET_API_BASE_MODELS: (category: string) => + `/base-models/?category=${category}&ordering=star_count&expand=stac`, + + GET_API_LOCAL_MODELS: (category: string) => + `/local-models/?category=${category}&ordering=star_count&expand=stac`, + // Map large area + MAP_LARGE_AREA_REQUEST: "/predictions/submit/", + + // Base }; diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index 6d92f47fb..275bbbd04 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -437,47 +437,26 @@ sl-alert.success::part(base) { hotosm-auth { --login-btn-border-radius: 0px; } - /* Try fAIr confidence slider starts */ -.try-fair-confidence-slider { - -webkit-appearance: none; - appearance: none; - width: 100%; - height: 24px; - background: transparent; - cursor: pointer; - touch-action: none; -} - -.try-fair-confidence-slider::-webkit-slider-runnable-track { - height: 6px; - border-radius: 9999px; - background: linear-gradient(90deg, #0088ff 0%, #ff383c 100%); -} - .try-fair-confidence-slider::-webkit-slider-thumb { - -webkit-appearance: none; appearance: none; - width: 18px; - height: 18px; - margin-top: -6px; + width: 14px; + height: 14px; border-radius: 50%; background: #687075; cursor: pointer; -} - -.try-fair-confidence-slider::-moz-range-track { - height: 6px; - border-radius: 9999px; - background: linear-gradient(90deg, #0088ff 0%, #ff383c 100%); + position: relative; + z-index: 20; } .try-fair-confidence-slider::-moz-range-thumb { - width: 18px; - height: 18px; + width: 14px; + height: 14px; border-radius: 50%; background: #687075; cursor: pointer; border: none; + position: relative; + z-index: 20; } /* Try fAIr confidence slider ends */ diff --git a/frontend/src/utils/geo/geometry-utils.ts b/frontend/src/utils/geo/geometry-utils.ts index 5b7fe76f9..80d30f7bc 100644 --- a/frontend/src/utils/geo/geometry-utils.ts +++ b/frontend/src/utils/geo/geometry-utils.ts @@ -5,7 +5,11 @@ import { createFeatureCollection } from "./geo-utils"; import { Feature, FeatureCollection, Polygon, Position } from "geojson"; import { LngLatBoundsLike, Map } from "maplibre-gl"; import { roundNumber } from "../number-utils"; -import { TModelPredictionFeature, TModelPredictionsConfig } from "@/types"; +import { + BBOX, + TModelPredictionFeature, + TModelPredictionsConfig, +} from "@/types"; import { uuid4 } from "../general-utils"; import { booleanWithin } from "@turf/boolean-within"; import { PredictedFeatureStatus } from "@/enums/start-mapping"; @@ -58,14 +62,12 @@ export function formatAreaInAppropriateUnit(area: number) { * * @param {Feature} geojsonFeature - The GeoJSON feature for which to calculate the bounding box. * - * @returns {LngLatBoundsLike} The bounding box as an array of coordinates + * @returns {BBOX} The bounding box as an array of coordinates * in the format [west, south, east, north]. */ -export const getGeoJSONFeatureBounds = ( - geojsonFeature: Feature, -): LngLatBoundsLike => { - return bboxPolygon(geojsonFeature) as [number, number, number, number]; +export const getGeoJSONFeatureBounds = (geojsonFeature: Feature): BBOX => { + return bboxPolygon(geojsonFeature) as BBOX; }; // Ref - https://github.com/hotosm/fAIr/blob/master/frontend/src/utils.js @@ -448,6 +450,23 @@ export const featureIsWithinBounds = ( return booleanWithin(feature, OAMFeature as Feature); }; +/** + * Checks if a single [lng, lat] coordinate falls within the specified bounding box. + * + * @param {LngLatBoundsLike} bounds - The bounding box [west, south, east, north]. + * @param {[number, number]} point - The [longitude, latitude] coordinate to check. + * + * @returns {boolean} True if the point is within the bounds, false otherwise. + */ +export const pointIsWithinBounds = ( + bounds: LngLatBoundsLike, + point: [number, number], +): boolean => { + const [west, south, east, north] = bounds as [number, number, number, number]; + const [lng, lat] = point; + return lng >= west && lng <= east && lat >= south && lat <= north; +}; + /** * Convert x/y meter to approximate longitude/latitude offsets based on the map center latitude. */