diff --git a/frontend/.env.sample b/frontend/.env.sample index d3dd7dd3e..4bacfeeb6 100644 --- a/frontend/.env.sample +++ b/frontend/.env.sample @@ -265,7 +265,7 @@ VITE_HANKO_AUTH_TOKEN = "xxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # The base URL for the FAIR STAC Catalog. # Data type: String (e.g., "https://stac.fair.krschap.tech/"). # Default value: "https://stac.fair.krschap.tech/". -FAIR_STAC_CATALOG_BASE_URL = "https://stac.fair.krschap.tech/" +VITE_FAIR_STAC_CATALOG_BASE_URL = "https://stac.fair.krschap.tech/" # The environment mode for the application. # Data type: String (e.g., "development", "production"). 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/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index 79117d7c1..d8bc2eb9b 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -7,14 +7,16 @@ import { ModelPickerContent } from "@/features/try-fair/components/model-picker- 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 { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getTileServerRegex, getTileServerTypeFromURL } from "@/utils"; import { useTryFairParams } from "@/features/try-fair/hooks/use-try-fair-params"; import { useBaseModels, useLocalModels, } from "@/features/try-fair/hooks/use-base-models"; -import { BBOX } from "@/types"; +import { BBOX, Feature } from "@/types"; +import { showSuccessToast } from "@/utils"; +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, @@ -38,10 +40,27 @@ import { } 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 { useNavigate } from "react-router-dom"; +import { APPLICATION_ROUTES } from "@/constants"; export const TryFairPage = () => { const { map, mapContainerRef } = useMapInstance(false, false); const { isSmallViewport } = useScreenSize(); + const { + showChooseLocationModal, + setShowChooseLocationModal, + showSigninModal, + setShowSigninModal, + currentModelType, + setCurrentModelType, + setSeletedImagery, + selectedImagery, + downloadType, + setDownloadType, + } = useStartMappingStore(); const { getValue, setValue } = useLocalStorage(); const { setIsOpen: setIsSiteTourOpen, setCurrentStep, setSteps } = useTour(); @@ -54,6 +73,8 @@ export const TryFairPage = () => { setOutputType, setResolution, setConfidence, + isParametersDefault, + resetParameters, } = useTryFairParams(); const { models: allModels, loading: modelsLoading } = useBaseModels(); @@ -89,7 +110,24 @@ export const TryFairPage = () => { 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 || !modelId) return null; + return JSON.stringify({ + modelId, + bbox: latestBBox, + gridZoom: latestGridZoom, + resolution, + paramValues, + }); + }, [modelId, latestBBox, latestGridZoom, resolution, paramValues]); + + const isDirty = + predictionInputsSnapshot === null || + predictionInputsSnapshot !== lastPredictedInputsRef.current; const { openDialog: openModelPickerDialog, @@ -107,11 +145,15 @@ export const TryFairPage = () => { ); const tileServiceUrl = useMemo(() => { - const modelImagery = selectedModel?.properties["fair:source_imagery"]; + const modelImagery = + currentModelType === "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; - }, [selectedModel]); + }, [selectedModel, currentModelType, selectedImagery]); + const { tileserverURL, setTileserverURL, @@ -157,6 +199,12 @@ export const TryFairPage = () => { }, [tileServiceUrl, setTileserverURL]); const imageryCenter = useMemo((): [number, number] => { + // A selected imagery's own bounds take priority, so applying imagery + // re-centers the map on it (OAM XYZ tiles carry no TileJSON center). + if (selectedImagery?.bounds) { + const [w, s, e, n] = selectedImagery.bounds; + return [(w + e) / 2, (s + n) / 2]; + } if (tileJSONMetadata?.center) { return [tileJSONMetadata.center[0], tileJSONMetadata.center[1]]; } @@ -172,18 +220,42 @@ export const TryFairPage = () => { return tileServiceUrl === FALLBACK_FAIR_IMAGERY ? FALLBACK_FAIR_IMAGERY_CENTER : DEFAULT_FAIR_IMAGERY_CENTER; - }, [tileJSONMetadata, tileServiceUrl]); + }, [tileJSONMetadata, tileServiceUrl, selectedImagery]); + + // The current imagery's extent, used as the "Whole Imagery" AOI in the + // Map Large Area modal. + const imageryBounds = useMemo(() => { + if (selectedImagery?.bounds) return selectedImagery.bounds; + if (tileJSONMetadata?.bounds) return tileJSONMetadata.bounds as BBOX; + return null; + }, [selectedImagery, tileJSONMetadata]); + + // Map Large Area (Export → Map Large Area). Opens when downloadType is set to + const navigate = useNavigate(); + const largeAreaAOIRef = useRef(null); + const handleLargeAreaSubmit = (aoi: Feature) => { + largeAreaAOIRef.current = aoi; + showSuccessToast("Area selected for mapping."); + setDownloadType(""); + navigate(APPLICATION_ROUTES.PROFILE_OFFLINE_PREDICTIONS); + }; + const mapFlownRef = useRef(false); useEffect(() => { if (!map || !selectedModel || !imageryCenter) return; const doFly = () => { + mapFlownRef.current = true; map.flyTo({ center: imageryCenter, zoom: TRY_FAIR_INITIAL_MAP_ZOOM, essential: true, }); }; - if (map.isStyleLoaded()) { + // After the first successful fly, fly directly on every center change. + // `isStyleLoaded()` transiently reports false while a newly-applied imagery + // source loads, and the map's one-shot `load` event has already fired — so + // re-gating on it here would trap the camera and it would never move. + if (mapFlownRef.current || map.isStyleLoaded()) { doFly(); } else { map.once("load", doFly); @@ -203,6 +275,7 @@ export const TryFairPage = () => { const handleSelectModel = (model: BaseModelStacItem) => { setModelId(model.id); + setCurrentModelType("demo"); setResolution(TryFairResolution.MID); // Reset confidence threshold to model's spec default if available @@ -213,19 +286,18 @@ export const TryFairPage = () => { if (confidenceParam && typeof confidenceParam.spec.default === "number") { setConfidence(confidenceParam.spec.default); } - 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], ); @@ -233,7 +305,6 @@ export const TryFairPage = () => { const handleBBoxChange = useCallback((bbox: BBOX, tileZoom: number) => { setLatestBBox(bbox); setLatestGridZoom(tileZoom); - setIsDirty(true); }, []); const guidedTourSteps = useMemo( @@ -274,7 +345,8 @@ export const TryFairPage = () => { // 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); + // Save what we just submitted so we can detect duplicate runs + lastPredictedInputsRef.current = predictionInputsSnapshot; setMapClickCount((n) => n + 1); const apiParams = Object.fromEntries( Object.entries(paramValues).map(([parameterName, parameterValue]) => @@ -301,6 +373,7 @@ export const TryFairPage = () => { tileserverURL, latestGridZoom, resolution, + predictionInputsSnapshot, ]); const isMapButtonDisabled = !isDirty || !latestBBox || !demoConfig; @@ -338,20 +411,49 @@ export const TryFairPage = () => { {/* Model picker dialog – rendered at page level so it's not trapped inside MobileDrawer */} { - handleSelectModel(model); - closeModelPickerDialog(); - }} + onSelect={handleSelectModel} models={models} + onClose={closeModelPickerDialog} /> + {/* Imagery/location dialog – rendered at page level */} + setShowChooseLocationModal(false)} + onApply={(selection) => { + setCurrentModelType("imagery"); + setSeletedImagery(selection); + // Invalidate the last prediction so the Map button re-enables and + // stale predictions clear when the imagery changes. The map re-centers + // on the new imagery via the imageryCenter/flyTo effect. + lastPredictedInputsRef.current = null; + clearPredictions(); + setShowChooseLocationModal(false); + }} + /> + setShowSigninModal(false)} + /> + + {/* Map Large Area (Export → Map Large Area) */} + setDownloadType("")} + tileServerURL={tileserverURL} + imageryBounds={imageryBounds} + onSubmit={handleLargeAreaSubmit} + /> + + {/* Signin Prompt */} +
{ inferenceParams={inferenceParams} paramValues={paramValues} onParamChange={handleParamChange} + onResetParameters={resetParameters} + isParametersDefault={isParametersDefault} onMap={handleMap} isPredicting={isPredicting} isMapButtonDisabled={isMapButtonDisabled} @@ -415,6 +519,8 @@ export const TryFairPage = () => { inferenceParams={inferenceParams} paramValues={paramValues} onParamChange={handleParamChange} + onResetParameters={resetParameters} + isParametersDefault={isParametersDefault} onMap={handleMap} isPredicting={isPredicting} isMapButtonDisabled={isMapButtonDisabled} 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/navbar.tsx b/frontend/src/components/layouts/navbar/navbar.tsx index 3f1a406a0..c94b4de84 100644 --- a/frontend/src/components/layouts/navbar/navbar.tsx +++ b/frontend/src/components/layouts/navbar/navbar.tsx @@ -26,6 +26,10 @@ import { 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"); @@ -155,13 +159,16 @@ 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..2efa81508 100644 --- a/frontend/src/components/map/setups/setup-terra-draw.ts +++ b/frontend/src/components/map/setups/setup-terra-draw.ts @@ -15,22 +15,35 @@ import { TRAINING_AREAS_AOI_OUTLINE_WIDTH, } from "@/config"; -export const setupTerraDraw = (map: maplibregl.Map) => { +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, +}; + +export const setupTerraDraw = ( + map: maplibregl.Map, + styleVariant: TerraDrawStyleVariant = "red", +) => { + const styles = styleVariant === "red" ? RED_AOI_STYLE : DEFAULT_AOI_STYLE; + 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: { @@ -64,6 +77,7 @@ export const setupTerraDraw = (map: maplibregl.Map) => { } return { valid: true }; }, + styles, }), new TerraDrawRectangleMode({ validation: (feature, { updateType }) => { @@ -74,21 +88,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..876720a16 100644 --- a/frontend/src/components/shared/form/xyz-tile-server-input.tsx +++ b/frontend/src/components/shared/form/xyz-tile-server-input.tsx @@ -1,4 +1,5 @@ import { Alert } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; import { HelpText, Input, Select } from "@/components/ui/form"; import { INPUT_TYPES, @@ -40,6 +41,10 @@ export const XYZTileServerInput = ({ size, tileServiceType, setTileServiceType, + variant = "horizontal", + showButton, + buttonOnclick, + useAlert = true, }: { tileServerURL: string; setTileServerURL: (url: string) => void; @@ -47,16 +52,26 @@ 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; }) => { 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/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 ( + ); + })} +
+ +
+ ); +}; + +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..57a93b293 --- /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..30a1873c7 --- /dev/null +++ b/frontend/src/features/try-fair/components/imagery/custom-imagery-form.tsx @@ -0,0 +1,117 @@ +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; + // Capture what the preview is showing as the imagery's extent, so its + // location can be labelled (place + country) on the model-picker card. + const b = map?.getBounds(); + const bounds: BBOX | null = b + ? [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..e67a925f2 --- /dev/null +++ b/frontend/src/features/try-fair/components/imagery/imagery-location-modal.tsx @@ -0,0 +1,187 @@ +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 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..dc2a2cdc1 --- /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..fd8d9f0fa --- /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..b792b9887 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,9 @@ 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"; type TryFairMapProps = { map: Map | null; @@ -60,18 +63,14 @@ export const TryFairMap = ({ onHelp, }: TryFairMapProps) => { const { isSmallViewport } = useScreenSize(); + const { setShowChooseLocationModal, setShowSigninModal } = + useStartMappingStore(); + const { isAuthenticated } = useAuth(); 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 +82,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 +181,23 @@ 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..553c32d7e --- /dev/null +++ b/frontend/src/features/try-fair/components/modals/share-project-modal.tsx @@ -0,0 +1,160 @@ +import { useForm, Controller } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import Dialog from "@/components/ui/dialog/dialog"; +import { Button } from "@/components/ui/button"; +import { CopyButton } from "@/components/ui/copy-button"; +import { Input, Switch } from "@/components/ui/form"; +import { CloseIcon, LinkIcon } from "@/components/ui/icons"; +import { SHOELACE_SIZES } from "@/enums"; +import { useStartMappingStore } from "@/features/try-fair/utils/start-mapping-store"; +import { showSuccessToast } from "@/utils"; + +const shareProjectSchema = z.object({ + email: z + .string() + .min(1, "Email is required") + .email("Please enter a valid email address"), + includeMapOutcome: z.boolean(), +}); + +type ShareProjectFormValues = z.infer; + +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 { + handleSubmit, + control, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(shareProjectSchema), + defaultValues: { + email: "", + includeMapOutcome: true, + }, + }); + + const handleClose = () => { + reset(); + if (externalCloseDialog) { + externalCloseDialog(); + } else { + storeSetIsOpened(false); + } + }; + + const onSubmit = (data: ShareProjectFormValues) => { + showSuccessToast(`Invitation sent to ${data.email}`); + reset({ email: "", includeMapOutcome: data.includeMapOutcome }); + }; + + return ( + +
+ {/* Header */} +
+

Share project

+ +
+ + {/* Form Body */} +
+ {/* Email input + Invite button */} +
+
+ ( + field.onChange(e.target.value)} + placeholder="Email" + className="flex-1" + showBorder + isValid={errors.email ? false : undefined} + /> + )} + /> + +
+ {errors.email && ( + + {errors.email.message} + + )} +
+ + {/* Toggle row */} +
+
+ + Include map outcome (Predictions) + + + Allow user to access the mapping result + +
+ + ( + + field.onChange(e.target.checked) + } + /> + )} + /> +
+ + {/* Bottom Copy Link */} +
+ +
+
+
+
+ ); +}; 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 9fe02b1d5..3011ffb46 100644 --- a/frontend/src/features/try-fair/components/model-picker-modal.tsx +++ b/frontend/src/features/try-fair/components/model-picker-modal.tsx @@ -1,9 +1,15 @@ -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"; import { BuildingIcon } from "@/components/ui/icons/buildings-icon"; +import { Button } from "@/components/ui/button"; +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 { ImagerySource } from "@/features/try-fair/components/imagery/imagery-location-modal"; type ModelPickerProps = { selectedModel: BaseModelStacItem | null; @@ -27,6 +33,32 @@ const FeatureBadge = ({ label }: { label: string | undefined }) => { ); }; +/** Radio indicator: filled primary dot when selected, empty grey ring otherwise. */ +const RadioDot = ({ selected }: { selected: boolean }) => ( + + {selected && } + +); + +/** ISO 3166-1 alpha-2 code → flag emoji (regional indicator symbols). */ +const flagEmoji = (code: string): string => + code.length === 2 + ? String.fromCodePoint( + ...[...code.toUpperCase()].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65), + ) + : "🏳️"; + +const CountryBadge = ({ country, code }: { country: string; code: string }) => ( + + {flagEmoji(code)} + {country} + +); + export const ModelPicker: React.FC = ({ selectedModel, onSelect, @@ -38,31 +70,31 @@ export const ModelPicker: React.FC = ({ }) => { const { onDropdownHide, dropdownRef } = useDropdownMenu(); const [isOpen, setIsOpen] = useState(false); + 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 === "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 ? ( - <> -

- {selectedModel.properties.title} -

- +

+ {selectedModel.properties.title} +

) : (

Select a model

)} @@ -102,9 +134,13 @@ export const ModelPicker: React.FC = ({
{ }
@@ -116,54 +152,186 @@ 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 tasks = model.properties["mlm:tasks"] ?? []; - return ( - + )} +
+

Default Locations

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

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

+

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

+ + +
+

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

+

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

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

+ No models available

-

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

+ 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..cd8693db0 --- /dev/null +++ b/frontend/src/features/try-fair/components/start-mapping/export-map-results.tsx @@ -0,0 +1,59 @@ +import { JOSMLogo } from "@/assets/svgs"; +import { DropDown } from "@/components/ui/dropdown"; +import { DropdownMenuItem } from "@/components/ui/dropdown/dropdown"; +import { + ChevronDownIcon, + CloudDownloadIcon, + MapIcon, +} from "@/components/ui/icons"; +import { DropdownPlacement } from "@/enums"; +import { useStartMappingStore } from "@/features/try-fair/utils/start-mapping-store"; + +export const EXPORT_MAP_MENU_ITEMS: Omit[] = [ + { + label: "Download Map Result", + value: "download", + Icon: CloudDownloadIcon, + }, + { + label: "Open in JOSM", + value: "josm", + imgSrc: JOSMLogo, + }, + { + label: "Map Large Area", + value: "large-area", + Icon: MapIcon, + dividerBefore: true, + }, +]; + +const ExportMapResults = () => { + const { setDownloadType } = useStartMappingStore(); + + const handleSelect = (value: string) => { + setDownloadType(value); + }; + + const menuItems: DropdownMenuItem[] = EXPORT_MAP_MENU_ITEMS.map((item) => ({ + ...item, + onClick: () => handleSelect(item.value), + })); + + 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..37d1fde0d --- /dev/null +++ b/frontend/src/features/try-fair/components/start-mapping/map-large-area-modal.tsx @@ -0,0 +1,164 @@ +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, SHOELACE_SIZES } from "@/enums"; +import { + AOITab, + useMapLargeArea, +} from "@/features/try-fair/hooks/use-map-large-area"; +import { BBOX, Feature, IconProps } from "@/types"; + +// ── Tabs ──────────────────────────────────────────────────────────────────────── + +const TABS: { value: AOITab; label: string; Icon: React.FC }[] = [ + { value: "whole", label: "Whole Imagery", 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: (aoi: Feature) => void; + closeDialog: () => void; +}) => { + const { + mapContainerRef, + map, + drawingMode, + setDrawingMode, + terraDraw, + activeTab, + selectedAOI, + uploadedFileName, + fileInputRef, + handleTabChange, + handleFileChange, + handleClearArea, + 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 === "upload" + ? uploadedFileName || "Mapping AOI.geojson" + : activeTab === "draw" + ? "Drawn AOI" + : "Whole Imagery AOI"} + + +
+ )} +
+ + {/* Footer Controls */} +
+ +
+
+ ); +}; + +export const MapLargeAreaModal = ({ + isOpened, + closeDialog, + tileServerURL, + imageryBounds, + onSubmit, +}: { + isOpened: boolean; + closeDialog: () => void; + tileServerURL?: string; + imageryBounds: BBOX | null; + onSubmit: (aoi: Feature) => void; +}) => { + return ( + + {isOpened && ( + + )} + + ); +}; 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..d5fe164c3 --- /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 d64ea6809..29fb9c8ff 100644 --- a/frontend/src/features/try-fair/components/try-fair-sidebar.tsx +++ b/frontend/src/features/try-fair/components/try-fair-sidebar.tsx @@ -8,13 +8,21 @@ 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 { OUTPUT_TYPES, RESOLUTIONS } from "@/features/try-fair/utils/common"; +import { + getAccuracyLabel, + OUTPUT_TYPES, + RESOLUTIONS, +} from "@/features/try-fair/utils/common"; import { BaseModelStacItem, InferenceParam, } 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; @@ -28,6 +36,8 @@ type TryFairSidebarProps = { inferenceParams: InferenceParam[]; paramValues: Record; onParamChange: (key: string, value: number | string | boolean) => void; + onResetParameters: () => void; + isParametersDefault: boolean; onMap: () => void; isPredicting: boolean; isMapButtonDisabled: boolean; @@ -47,6 +57,8 @@ export const TryFairSidebar = ({ inferenceParams, paramValues, onParamChange, + onResetParameters, + isParametersDefault, onMap, isPredicting, isMapButtonDisabled, @@ -54,7 +66,7 @@ export const TryFairSidebar = ({ openMobileModelPickerDialog, }: TryFairSidebarProps) => { const { isSmallViewport } = useScreenSize(); - + const { currentModelType } = useStartMappingStore(); return (
+ {currentModelType !== "demo" && } +

{TRY_FAIR_PAGE_CONTENT.sidebar.mapOutput.label} @@ -135,11 +149,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 */} @@ -186,36 +217,41 @@ export const TryFairSidebar = ({ return (
-
-

Confidence

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

Accuracy

+ + {getAccuracyLabel(value)}
- - onParamChange(key, parseFloat(e.target.value)) - } - className="try-fair-confidence-slider disabled:cursor-wait flex-1 h-1.5 rounded-full appearance-none cursor-pointer outline-none" - style={{ - background: `linear-gradient(90deg, #0088FF 0%, #FF383C 100%)`, - }} - /> +
+ {[25, 50, 75].map((pct) => ( +
+ ))} + + onParamChange(key, 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%)`, + }} + /> +
-
- Low - High -
); })} 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-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..aa8a6da9a --- /dev/null +++ b/frontend/src/features/try-fair/hooks/use-map-large-area.ts @@ -0,0 +1,411 @@ +import { DrawingModes } from "@/enums"; +import { useMapInstance } from "@/hooks/use-map-instance"; +import { BBOX, Feature } from "@/types"; +import { + featureIsWithinBounds, + getGeoJSONFeatureBounds, + showErrorToast, + showSuccessToast, + showWarningToast, + uuid4, +} from "@/utils"; +import { GeoJSONStoreFeatures } from "terra-draw"; +import { FeatureCollection, Polygon } from "geojson"; +import { GeoJSONSource } from "maplibre-gl"; +import { useCallback, useEffect, useRef, useState } from "react"; + +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: (aoi: Feature) => void; + closeDialog: () => void; +} + +export const useMapLargeArea = ({ + imageryBounds, + onSubmit, + closeDialog, +}: UseMapLargeAreaOptions) => { + const { mapContainerRef, map, drawingMode, setDrawingMode, terraDraw } = + useMapInstance(undefined, undefined, "red"); + 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 || !imageryBounds) return; + map.resize(); + map.fitBounds( + [imageryBounds[0], imageryBounds[1], imageryBounds[2], imageryBounds[3]], + { + padding: 40, + maxZoom: 18, + essential: true, + }, + ); + }, [map, imageryBounds]); + + // 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]); + + // Handle Tab Switch + const handleTabChange = useCallback( + (tab: AOITab) => { + setActiveTab(tab); + + if (tab === "whole") { + setDrawingMode(DrawingModes.STATIC); + setUploadedFileName(null); + 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 + } + } + if (imageryBounds && imageryBounds.length === 4) { + const wholeFeature = createFeatureFromBounds(imageryBounds); + if (terraDraw) { + terraDraw.addFeatures([wholeFeature] as GeoJSONStoreFeatures[]); + } + setSelectedAOI(wholeFeature); + if (map) { + map.fitBounds( + [ + imageryBounds[0], + imageryBounds[1], + imageryBounds[2], + imageryBounds[3], + ], + { + padding: 40, + maxZoom: 18, + essential: true, + }, + ); + } + } else { + showWarningToast("Imagery bounds are not available."); + } + } else if (tab === "draw") { + setDrawingMode(DrawingModes.POLYGON); + setUploadedFileName(null); + } else if (tab === "upload") { + setDrawingMode(DrawingModes.STATIC); + triggerFileSelect(); + } + }, + [imageryBounds, map, setDrawingMode, terraDraw], + ); + + // Set default mode on initial mount if tab is draw + useEffect(() => { + if (activeTab === "draw") { + setDrawingMode(DrawingModes.POLYGON); + } + }, [activeTab, setDrawingMode]); + + // 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]; + + if (imageryBounds && imageryBounds.length === 4) { + if (!featureIsWithinBounds(imageryBounds, latestFeature)) { + showWarningToast( + "The drawn polygon extends beyond the imagery bounds. Please draw within the imagery bounds.", + ); + terraDraw.clear(); + setSelectedAOI(null); + 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); + }, [terraDraw, imageryBounds]); + + 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 (imageryBounds && imageryBounds.length === 4) { + if (!featureIsWithinBounds(imageryBounds, uploadedFeature)) { + showWarningToast( + "The uploaded polygon extends beyond the imagery bounds. Zooming to polygon location.", + ); + } + } + + 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(); + } + 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); + } + } + setSelectedAOI(null); + setUploadedFileName(null); + + if (activeTab === "draw") { + setDrawingMode(DrawingModes.STATIC); + setTimeout(() => { + setDrawingMode(DrawingModes.POLYGON); + }, 50); + } else { + setDrawingMode(DrawingModes.STATIC); + } + showSuccessToast("Selected area cleared."); + }; + + const handleSubmit = () => { + if (!selectedAOI) return; + onSubmit(selectedAOI); + closeDialog(); + }; + + return { + mapContainerRef, + map, + drawingMode, + setDrawingMode, + terraDraw, + activeTab, + selectedAOI, + uploadedFileName, + fileInputRef, + handleTabChange, + handleFileChange, + handleClearArea, + 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-try-fair-params.tsx b/frontend/src/features/try-fair/hooks/use-try-fair-params.tsx index 2c535e8cd..034213785 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,21 @@ import { TryFairMapOutputType, TryFairResolution } from "@/enums/try-fair"; import { parseAsFloat, parseAsString, useQueryStates } from "nuqs"; +import { useBaseModels, useLocalModels } 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, +} as const; + /** * Persists the Try fAIr sidebar UI state in URL search params via nuqs. * @@ -16,14 +28,42 @@ const VALID_RESOLUTIONS = Object.values(TryFairResolution) as string[]; 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, }, { history: "replace" }, ); + const { models: allModels } = useBaseModels(); + const { models: localModels } = useLocalModels(); + + 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 +72,30 @@ export const useTryFairParams = () => { ? (params.resolution as TryFairResolution) : TryFairResolution.LOW; + const confidence = params.confidence ?? defaultConfidence; + + 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, outputType, resolution, - confidence: params.confidence, + confidence, 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 }), + + isParametersDefault, + resetParameters, }; }; 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 103ab0d13..cabbfdc63 100644 --- a/frontend/src/features/try-fair/utils/common.tsx +++ b/frontend/src/features/try-fair/utils/common.tsx @@ -5,6 +5,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. @@ -63,9 +71,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, }; // Prediction layer IDs (kept in sync with try-fair-prediction-results.tsx) @@ -104,3 +112,83 @@ 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", + }, +]; 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..b16697edc --- /dev/null +++ b/frontend/src/features/try-fair/utils/start-mapping-store.ts @@ -0,0 +1,35 @@ +// store/zoomStore.ts +import { ImagerySelection } from "@/features/try-fair/types/imagery-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: "demo" | "imagery"; + setCurrentModelType: (type: "demo" | "imagery") => 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: "demo", + setCurrentModelType: (currentModelType) => set({ currentModelType }), +})); 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..1098278a3 100644 --- a/frontend/src/hooks/use-map-instance.ts +++ b/frontend/src/hooks/use-map-instance.ts @@ -1,7 +1,10 @@ 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"; @@ -9,11 +12,14 @@ import { useMapStore } from "@/store/map-store"; * 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". * @returns {Object} - Contains map instance, zoom level, drawing mode, and container ref. */ export const useMapInstance = ( pmtiles: boolean = false, hash: boolean = false, + styleVariant: TerraDrawStyleVariant = "default", ) => { const mapContainerRef = useRef(null); const [map, setMap] = useState(null); @@ -38,11 +44,11 @@ export const useMapInstance = ( const terraDraw = useMemo(() => { if (map) { - const draw = setupTerraDraw(map); + const draw = setupTerraDraw(map, styleVariant); draw.start(); return draw; } - }, [map]); + }, [map, styleVariant]); // Sync the drawing modes between terraDraw // and the application state diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index cf1f634ca..790bd7342 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -446,6 +446,8 @@ hotosm-auth { border-radius: 50%; background: #687075; cursor: pointer; + position: relative; + z-index: 20; } .try-fair-confidence-slider::-moz-range-thumb { @@ -455,5 +457,7 @@ hotosm-auth { background: #687075; cursor: pointer; border: none; + position: relative; + z-index: 20; } /* Try fAIr confidence slider ends */