From ef21223f4f7fd01d3e276f94dc48d8ebda3ffb59 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Fri, 3 Jul 2026 19:24:57 +0100 Subject: [PATCH 01/36] chore: added empty state texts --- .../components/model-picker-modal.tsx | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) 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 9fe02b1d..ee41fead 100644 --- a/frontend/src/features/try-fair/components/model-picker-modal.tsx +++ b/frontend/src/features/try-fair/components/model-picker-modal.tsx @@ -69,9 +69,8 @@ export const ModelPicker: React.FC = ({ ); @@ -127,7 +126,7 @@ export const ModelPickerContent = ({ }) => (
- {models.map((model) => { + {models.length > 0 ? models.map((model) => { const isSelected = selectedModel?.id === model.id; // const tasks = model.properties["mlm:tasks"] ?? []; return ( @@ -135,9 +134,8 @@ export const ModelPickerContent = ({ key={model.id} type="button" onClick={() => onSelect(model)} - className={`text-left p-3 bg-frosted-blue rounded-lg transition-colors ${ - isSelected ? "border-primary border-2" : "" - }`} + className={`text-left p-3 bg-frosted-blue rounded-lg transition-colors ${isSelected ? "border-primary border-2" : "" + }`} >

@@ -145,9 +143,8 @@ export const ModelPickerContent = ({

{isSelected && ( @@ -163,7 +160,15 @@ export const ModelPickerContent = ({ ); - })} + }) : ( +
+ +

No models available

+

+ There are currently no models available for use.

+
+ ) + }
); From 7daa2679f3b4a2e79be8afdda8392d27eb714fee Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Fri, 3 Jul 2026 19:25:34 +0100 Subject: [PATCH 02/36] chore: added empty state texts --- .../components/model-picker-modal.tsx | 84 ++++++++++--------- 1 file changed, 45 insertions(+), 39 deletions(-) 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 ee41fead..45f6fc43 100644 --- a/frontend/src/features/try-fair/components/model-picker-modal.tsx +++ b/frontend/src/features/try-fair/components/model-picker-modal.tsx @@ -69,8 +69,9 @@ export const ModelPicker: React.FC = ({
); @@ -126,49 +127,54 @@ export const ModelPickerContent = ({ }) => (
- {models.length > 0 ? models.map((model) => { - const isSelected = selectedModel?.id === model.id; - // const tasks = model.properties["mlm:tasks"] ?? []; - return ( - - ); - }) : ( + > + {isSelected && ( + + )} + +
+

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

+

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

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

No models available

+

+ No models available +

- There are currently no models available for use.

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

- ) - } + )}
); From 591218cd5ae3fe8161d3d050d01b74c26e2f5069 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Fri, 3 Jul 2026 19:41:04 +0100 Subject: [PATCH 03/36] updated models text --- .../src/features/try-fair/components/model-picker-modal.tsx | 2 ++ 1 file changed, 2 insertions(+) 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 45f6fc43..ca07c25d 100644 --- a/frontend/src/features/try-fair/components/model-picker-modal.tsx +++ b/frontend/src/features/try-fair/components/model-picker-modal.tsx @@ -57,6 +57,8 @@ export const ModelPicker: React.FC = ({
{loading ? (

Loading models…

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

No models available

) : selectedModel ? ( <>

From d62647ab67ea10a2c361066e8b9ffcdcc506a101 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 14 Jul 2026 09:20:52 +0100 Subject: [PATCH 04/36] chore: added review updates --- frontend/src/app/routes/try-fair.tsx | 31 ++++++++--- .../src/components/ui/icons/refresh-icon.tsx | 20 ++++--- .../ui-contents/try-fair-contents.ts | 2 +- .../try-fair/components/try-fair-sidebar.tsx | 53 ++++++++++++++----- .../src/features/try-fair/utils/common.tsx | 4 +- 5 files changed, 77 insertions(+), 33 deletions(-) diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index 79117d7c..cd684b7f 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -7,7 +7,7 @@ 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 { @@ -89,7 +89,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, @@ -213,19 +230,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 +249,6 @@ export const TryFairPage = () => { const handleBBoxChange = useCallback((bbox: BBOX, tileZoom: number) => { setLatestBBox(bbox); setLatestGridZoom(tileZoom); - setIsDirty(true); }, []); const guidedTourSteps = useMemo( @@ -274,7 +289,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 +317,7 @@ export const TryFairPage = () => { tileserverURL, latestGridZoom, resolution, + predictionInputsSnapshot, ]); const isMapButtonDisabled = !isDirty || !latestBBox || !demoConfig; diff --git a/frontend/src/components/ui/icons/refresh-icon.tsx b/frontend/src/components/ui/icons/refresh-icon.tsx index 4a101f05..144b0eb8 100644 --- a/frontend/src/components/ui/icons/refresh-icon.tsx +++ b/frontend/src/components/ui/icons/refresh-icon.tsx @@ -3,18 +3,16 @@ import React from "react"; export const RefreshIcon: React.FC = (props) => ( - - - - + ); diff --git a/frontend/src/constants/ui-contents/try-fair-contents.ts b/frontend/src/constants/ui-contents/try-fair-contents.ts index aae39f2e..5fa4e221 100644 --- a/frontend/src/constants/ui-contents/try-fair-contents.ts +++ b/frontend/src/constants/ui-contents/try-fair-contents.ts @@ -21,7 +21,7 @@ export const TRY_FAIR_PAGE_CONTENT: TTryFairPageContent = { "Adjust confidence and resolution to explore different prediction results.", learnMore: "Learn more", resolution: { - label: "Resolution", + label: "Size", low: "Low", mid: "Mid", high: "High", 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 d64ea680..59dd492f 100644 --- a/frontend/src/features/try-fair/components/try-fair-sidebar.tsx +++ b/frontend/src/features/try-fair/components/try-fair-sidebar.tsx @@ -15,6 +15,8 @@ import { } from "@/features/try-fair/api/stac"; import { cn } from "@/utils"; import useScreenSize from "@/hooks/use-screen-size"; +import { RefreshIcon } from "@/components/ui/icons"; +import { ToolTip } from "@/components/ui/tooltip"; type TryFairSidebarProps = { selectedModel: BaseModelStacItem | null; @@ -117,11 +119,10 @@ export const TryFairSidebar = ({ title={label} disabled={isPredicting} aria-label={label} - className={`flex-1 flex disabled:cursor-wait items-center justify-center py-2 rounded-lg ${ - outputType === type + className={`flex-1 flex disabled:cursor-wait items-center justify-center py-2 rounded-lg ${outputType === type ? "bg-secondary text-primary border-[#D63F4080] border" : "bg-off-white" - }`} + }`} > {icon} @@ -135,11 +136,21 @@ 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 */} @@ -164,11 +175,10 @@ export const TryFairSidebar = ({ type="button" disabled={isPredicting} onClick={() => onResolutionChange(value)} - className={`flex-1 gap-1 flex disabled:cursor-wait text-xs items-center justify-center py-2 rounded-lg ${ - resolution === value + className={`flex-1 gap-1 flex disabled:cursor-wait text-xs items-center justify-center py-2 rounded-lg ${resolution === value ? "bg-secondary border-[#D63F4080] border" : "bg-off-white" - }`} + }`} > {label} @@ -187,7 +197,7 @@ export const TryFairSidebar = ({ return (
-

Confidence

+

Accuracy

{Math.round(Number(value) * 100)}% @@ -200,6 +210,7 @@ export const TryFairSidebar = ({ min={min} max={max} step={0.01} + disabled={isPredicting} value={Number(value)} onChange={(e) => @@ -219,6 +230,24 @@ export const TryFairSidebar = ({
); })} + {/*
+ + + + +
*/}
); diff --git a/frontend/src/features/try-fair/utils/common.tsx b/frontend/src/features/try-fair/utils/common.tsx index 103ab0d1..9983c119 100644 --- a/frontend/src/features/try-fair/utils/common.tsx +++ b/frontend/src/features/try-fair/utils/common.tsx @@ -63,9 +63,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) From 4eaa4734a74637988f83d2ccce15bf69a8481c43 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 14 Jul 2026 09:23:34 +0100 Subject: [PATCH 05/36] updated header banner --- frontend/src/components/landing/header/header.tsx | 9 ++++----- .../try-fair/components/try-fair-sidebar.tsx | 15 +++++++-------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/landing/header/header.tsx b/frontend/src/components/landing/header/header.tsx index 729c8502..568f2682 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 ( @@ -21,11 +20,11 @@ export const Header = () => { title={SHARED_CONTENT.homepage.ctaPrimaryButton} nativeAnchor={false} > - - { - + */}
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 59dd492f..52914692 100644 --- a/frontend/src/features/try-fair/components/try-fair-sidebar.tsx +++ b/frontend/src/features/try-fair/components/try-fair-sidebar.tsx @@ -119,10 +119,11 @@ export const TryFairSidebar = ({ title={label} disabled={isPredicting} aria-label={label} - className={`flex-1 flex disabled:cursor-wait items-center justify-center py-2 rounded-lg ${outputType === type + className={`flex-1 flex disabled:cursor-wait items-center justify-center py-2 rounded-lg ${ + outputType === type ? "bg-secondary text-primary border-[#D63F4080] border" : "bg-off-white" - }`} + }`} > {icon} @@ -144,11 +145,9 @@ export const TryFairSidebar = ({

- + @@ -175,10 +174,11 @@ export const TryFairSidebar = ({ type="button" disabled={isPredicting} onClick={() => onResolutionChange(value)} - className={`flex-1 gap-1 flex disabled:cursor-wait text-xs items-center justify-center py-2 rounded-lg ${resolution === value + className={`flex-1 gap-1 flex disabled:cursor-wait text-xs items-center justify-center py-2 rounded-lg ${ + resolution === value ? "bg-secondary border-[#D63F4080] border" : "bg-off-white" - }`} + }`} > {label} @@ -210,7 +210,6 @@ export const TryFairSidebar = ({ min={min} max={max} step={0.01} - disabled={isPredicting} value={Number(value)} onChange={(e) => From 8f4971db35542fa75ba7a3d80ddd6adba06c1082 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 14 Jul 2026 09:25:43 +0100 Subject: [PATCH 06/36] updated header banner --- frontend/src/components/landing/header/header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/landing/header/header.tsx b/frontend/src/components/landing/header/header.tsx index 568f2682..8c12cf8d 100644 --- a/frontend/src/components/landing/header/header.tsx +++ b/frontend/src/components/landing/header/header.tsx @@ -20,7 +20,7 @@ export const Header = () => { title={SHARED_CONTENT.homepage.ctaPrimaryButton} nativeAnchor={false} > - From 9d0762da0dd7853527f29a61540cc7e94c7d79e8 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 14 Jul 2026 15:24:51 +0100 Subject: [PATCH 07/36] feat: added snap to grid --- frontend/src/app/routes/try-fair.tsx | 6 ++++ .../try-fair/components/map/try-fair-map.tsx | 36 ++++++++++++++----- .../try-fair/components/try-fair-sidebar.tsx | 15 +++++++- .../try-fair/hooks/use-try-fair-params.tsx | 29 ++++++++++++--- 4 files changed, 73 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index cd684b7f..8641fb1d 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -54,6 +54,8 @@ export const TryFairPage = () => { setOutputType, setResolution, setConfidence, + isParametersDefault, + resetParameters, } = useTryFairParams(); const { models: allModels, loading: modelsLoading } = useBaseModels(); @@ -404,6 +406,8 @@ export const TryFairPage = () => { inferenceParams={inferenceParams} paramValues={paramValues} onParamChange={handleParamChange} + onResetParameters={resetParameters} + isParametersDefault={isParametersDefault} onMap={handleMap} isPredicting={isPredicting} isMapButtonDisabled={isMapButtonDisabled} @@ -432,6 +436,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/features/try-fair/components/map/try-fair-map.tsx b/frontend/src/features/try-fair/components/map/try-fair-map.tsx index 5c57569a..f0bb7cd1 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 @@ -64,14 +64,7 @@ export const TryFairMap = ({ 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 +76,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) { 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 52914692..048be39b 100644 --- a/frontend/src/features/try-fair/components/try-fair-sidebar.tsx +++ b/frontend/src/features/try-fair/components/try-fair-sidebar.tsx @@ -30,6 +30,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; @@ -49,6 +51,8 @@ export const TryFairSidebar = ({ inferenceParams, paramValues, onParamChange, + onResetParameters, + isParametersDefault, onMap, isPredicting, isMapButtonDisabled, @@ -146,7 +150,16 @@ export const TryFairSidebar = ({ - 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 2c535e8c..009ec43c 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 @@ -4,6 +4,14 @@ import { parseAsFloat, parseAsString, useQueryStates } from "nuqs"; 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,10 +24,10 @@ 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.withDefault(TRY_FAIR_PARAM_DEFAULTS.confidence), }, { history: "replace" }, ); @@ -32,6 +40,16 @@ export const useTryFairParams = () => { ? (params.resolution as TryFairResolution) : TryFairResolution.LOW; + const isParametersDefault = + resolution === TRY_FAIR_PARAM_DEFAULTS.resolution && + params.confidence === TRY_FAIR_PARAM_DEFAULTS.confidence; + + const resetParameters = () => + setParams({ + resolution: TRY_FAIR_PARAM_DEFAULTS.resolution, + confidence: TRY_FAIR_PARAM_DEFAULTS.confidence, + }); + return { modelId: params.model, outputType, @@ -42,5 +60,8 @@ export const useTryFairParams = () => { setOutputType: (type: TryFairMapOutputType) => setParams({ output: type }), setResolution: (res: TryFairResolution) => setParams({ resolution: res }), setConfidence: (val: number) => setParams({ confidence: val }), + + isParametersDefault, + resetParameters, }; }; From b2ba8a1eb945344fbd212f835b13668d8ba3d09d Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 14 Jul 2026 19:36:26 +0100 Subject: [PATCH 08/36] updated range slider --- .../try-fair/components/try-fair-sidebar.tsx | 59 ++++++++----------- frontend/src/styles/index.css | 4 ++ 2 files changed, 30 insertions(+), 33 deletions(-) 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 048be39b..37134a98 100644 --- a/frontend/src/features/try-fair/components/try-fair-sidebar.tsx +++ b/frontend/src/features/try-fair/components/try-fair-sidebar.tsx @@ -218,21 +218,31 @@ export const TryFairSidebar = ({
- - 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%)`, - }} - /> +
+ {/* Break lines at 25%, 50%, 75% */} + {[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%)`, + }} + /> +
@@ -242,24 +252,7 @@ export const TryFairSidebar = ({
); })} - {/*
- - - - -
*/} +
); diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index cf1f634c..790bd734 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 */ From 50c778a76c001d7bd8f5102dd78c6103efab7ecf Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 14 Jul 2026 19:37:28 +0100 Subject: [PATCH 09/36] updated range slider --- frontend/src/features/try-fair/components/try-fair-sidebar.tsx | 1 - 1 file changed, 1 deletion(-) 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 37134a98..c98f19fb 100644 --- a/frontend/src/features/try-fair/components/try-fair-sidebar.tsx +++ b/frontend/src/features/try-fair/components/try-fair-sidebar.tsx @@ -252,7 +252,6 @@ export const TryFairSidebar = ({ ); })} - ); From 0d9eda2d4062d65629bb8886b5bb7305c6306385 Mon Sep 17 00:00:00 2001 From: Emmanuel Jolaiya Date: Wed, 15 Jul 2026 11:21:59 +0200 Subject: [PATCH 10/36] Rename FAIR_STAC_CATALOG_BASE_URL to VITE_FAIR_STAC_CATALOG_BASE_URL --- frontend/.env.sample | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/.env.sample b/frontend/.env.sample index d3dd7dd3..4bacfeeb 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"). From 0911becdf102e61cbf3927f9bc6ed05e79a66aa2 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Wed, 15 Jul 2026 10:23:51 +0100 Subject: [PATCH 11/36] updated default params --- frontend/src/app/routes/try-fair.tsx | 38 +++++++++++++++---- .../src/components/landing/header/header.tsx | 2 +- .../try-fair/components/try-fair-sidebar.tsx | 6 ++- .../try-fair/hooks/use-try-fair-params.tsx | 17 +-------- .../src/features/try-fair/utils/common.tsx | 18 +++++++++ 5 files changed, 55 insertions(+), 26 deletions(-) diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index 8641fb1d..36b7f608 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -9,7 +9,7 @@ import { useMapInstance } from "@/hooks/use-map-instance"; import { useTileservice } from "@/hooks/use-tileservice"; 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 { useTryFairParams, TRY_FAIR_PARAM_DEFAULTS } from "@/features/try-fair/hooks/use-try-fair-params"; import { useBaseModels, useLocalModels, @@ -49,13 +49,11 @@ export const TryFairPage = () => { modelId, outputType, resolution, - confidence, + confidence: urlConfidence, setModelId, setOutputType, setResolution, setConfidence, - isParametersDefault, - resetParameters, } = useTryFairParams(); const { models: allModels, loading: modelsLoading } = useBaseModels(); @@ -80,6 +78,30 @@ export const TryFairPage = () => { [selectedModel], ); + const defaultConfidence = useMemo(() => { + const confidenceParam = inferenceParams.find( + (p) => p.key === "confidence_threshold", + ); + if (confidenceParam && typeof confidenceParam.spec.default === "number") { + return confidenceParam.spec.default; + } + return TRY_FAIR_PARAM_DEFAULTS.confidence; + }, [inferenceParams]); + + const confidence = urlConfidence ?? defaultConfidence; + + const isParamsDefault = useMemo(() => { + return ( + resolution === TRY_FAIR_PARAM_DEFAULTS.resolution && + (urlConfidence === null || urlConfidence === defaultConfidence) + ); + }, [resolution, urlConfidence, defaultConfidence]); + + const handleResetParameters = useCallback(() => { + setResolution(TRY_FAIR_PARAM_DEFAULTS.resolution); + setConfidence(defaultConfidence); + }, [setResolution, setConfidence, defaultConfidence]); + const paramValues = useMemo(() => { const values: Record = {}; inferenceParams.forEach(({ key, spec }) => { @@ -406,8 +428,8 @@ export const TryFairPage = () => { inferenceParams={inferenceParams} paramValues={paramValues} onParamChange={handleParamChange} - onResetParameters={resetParameters} - isParametersDefault={isParametersDefault} + onResetParameters={handleResetParameters} + isParametersDefault={isParamsDefault} onMap={handleMap} isPredicting={isPredicting} isMapButtonDisabled={isMapButtonDisabled} @@ -436,8 +458,8 @@ export const TryFairPage = () => { inferenceParams={inferenceParams} paramValues={paramValues} onParamChange={handleParamChange} - onResetParameters={resetParameters} - isParametersDefault={isParametersDefault} + onResetParameters={handleResetParameters} + isParametersDefault={isParamsDefault} 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 8c12cf8d..d564c8cd 100644 --- a/frontend/src/components/landing/header/header.tsx +++ b/frontend/src/components/landing/header/header.tsx @@ -20,7 +20,7 @@ export const Header = () => { title={SHARED_CONTENT.homepage.ctaPrimaryButton} nativeAnchor={false} > - 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 c98f19fb..f143498a 100644 --- a/frontend/src/features/try-fair/components/try-fair-sidebar.tsx +++ b/frontend/src/features/try-fair/components/try-fair-sidebar.tsx @@ -8,7 +8,7 @@ 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, @@ -39,6 +39,8 @@ type TryFairSidebarProps = { openMobileModelPickerDialog?: () => void; }; + + export const TryFairSidebar = ({ selectedModel, models, @@ -212,7 +214,7 @@ export const TryFairSidebar = ({

Accuracy

- {Math.round(Number(value) * 100)}% + {getAccuracyLabel(value)}
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 009ec43c..e26a4518 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 @@ -27,7 +27,7 @@ export const useTryFairParams = () => { 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.withDefault(TRY_FAIR_PARAM_DEFAULTS.confidence), + confidence: parseAsFloat, }, { history: "replace" }, ); @@ -40,16 +40,6 @@ export const useTryFairParams = () => { ? (params.resolution as TryFairResolution) : TryFairResolution.LOW; - const isParametersDefault = - resolution === TRY_FAIR_PARAM_DEFAULTS.resolution && - params.confidence === TRY_FAIR_PARAM_DEFAULTS.confidence; - - const resetParameters = () => - setParams({ - resolution: TRY_FAIR_PARAM_DEFAULTS.resolution, - confidence: TRY_FAIR_PARAM_DEFAULTS.confidence, - }); - return { modelId: params.model, outputType, @@ -59,9 +49,6 @@ export const useTryFairParams = () => { setModelId: (id: string) => setParams({ model: id }), setOutputType: (type: TryFairMapOutputType) => setParams({ output: type }), setResolution: (res: TryFairResolution) => setParams({ resolution: res }), - setConfidence: (val: number) => setParams({ confidence: val }), - - isParametersDefault, - resetParameters, + setConfidence: (val: number | null) => setParams({ confidence: val }), }; }; diff --git a/frontend/src/features/try-fair/utils/common.tsx b/frontend/src/features/try-fair/utils/common.tsx index 9983c119..2736469a 100644 --- a/frontend/src/features/try-fair/utils/common.tsx +++ b/frontend/src/features/try-fair/utils/common.tsx @@ -104,3 +104,21 @@ 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"; +}; \ No newline at end of file From ea76107339116680755f1706da7f63702ad1624d Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Wed, 15 Jul 2026 10:24:32 +0100 Subject: [PATCH 12/36] chore: updated params name --- frontend/src/app/routes/try-fair.tsx | 38 +++----------- .../try-fair/components/try-fair-sidebar.tsx | 8 +-- .../try-fair/hooks/use-try-fair-params.tsx | 49 ++++++++++++++++++- .../src/features/try-fair/utils/common.tsx | 2 +- 4 files changed, 62 insertions(+), 35 deletions(-) diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index 36b7f608..8641fb1d 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -9,7 +9,7 @@ import { useMapInstance } from "@/hooks/use-map-instance"; import { useTileservice } from "@/hooks/use-tileservice"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getTileServerRegex, getTileServerTypeFromURL } from "@/utils"; -import { useTryFairParams, TRY_FAIR_PARAM_DEFAULTS } from "@/features/try-fair/hooks/use-try-fair-params"; +import { useTryFairParams } from "@/features/try-fair/hooks/use-try-fair-params"; import { useBaseModels, useLocalModels, @@ -49,11 +49,13 @@ export const TryFairPage = () => { modelId, outputType, resolution, - confidence: urlConfidence, + confidence, setModelId, setOutputType, setResolution, setConfidence, + isParametersDefault, + resetParameters, } = useTryFairParams(); const { models: allModels, loading: modelsLoading } = useBaseModels(); @@ -78,30 +80,6 @@ export const TryFairPage = () => { [selectedModel], ); - const defaultConfidence = useMemo(() => { - const confidenceParam = inferenceParams.find( - (p) => p.key === "confidence_threshold", - ); - if (confidenceParam && typeof confidenceParam.spec.default === "number") { - return confidenceParam.spec.default; - } - return TRY_FAIR_PARAM_DEFAULTS.confidence; - }, [inferenceParams]); - - const confidence = urlConfidence ?? defaultConfidence; - - const isParamsDefault = useMemo(() => { - return ( - resolution === TRY_FAIR_PARAM_DEFAULTS.resolution && - (urlConfidence === null || urlConfidence === defaultConfidence) - ); - }, [resolution, urlConfidence, defaultConfidence]); - - const handleResetParameters = useCallback(() => { - setResolution(TRY_FAIR_PARAM_DEFAULTS.resolution); - setConfidence(defaultConfidence); - }, [setResolution, setConfidence, defaultConfidence]); - const paramValues = useMemo(() => { const values: Record = {}; inferenceParams.forEach(({ key, spec }) => { @@ -428,8 +406,8 @@ export const TryFairPage = () => { inferenceParams={inferenceParams} paramValues={paramValues} onParamChange={handleParamChange} - onResetParameters={handleResetParameters} - isParametersDefault={isParamsDefault} + onResetParameters={resetParameters} + isParametersDefault={isParametersDefault} onMap={handleMap} isPredicting={isPredicting} isMapButtonDisabled={isMapButtonDisabled} @@ -458,8 +436,8 @@ export const TryFairPage = () => { inferenceParams={inferenceParams} paramValues={paramValues} onParamChange={handleParamChange} - onResetParameters={handleResetParameters} - isParametersDefault={isParamsDefault} + onResetParameters={resetParameters} + isParametersDefault={isParametersDefault} onMap={handleMap} isPredicting={isPredicting} isMapButtonDisabled={isMapButtonDisabled} 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 f143498a..c0ce47fc 100644 --- a/frontend/src/features/try-fair/components/try-fair-sidebar.tsx +++ b/frontend/src/features/try-fair/components/try-fair-sidebar.tsx @@ -8,7 +8,11 @@ import { ParametersIcon } from "@/components/ui/icons/parameters-icon"; import { SnowflakeIcon } from "@/components/ui/icons/snow-flake-icon"; import { GridIcon } from "@/components/ui/icons/grid-icon"; import { FlameIcon } from "@/components/ui/icons/flame-icon"; -import { getAccuracyLabel, OUTPUT_TYPES, RESOLUTIONS } from "@/features/try-fair/utils/common"; +import { + getAccuracyLabel, + OUTPUT_TYPES, + RESOLUTIONS, +} from "@/features/try-fair/utils/common"; import { BaseModelStacItem, InferenceParam, @@ -39,8 +43,6 @@ type TryFairSidebarProps = { openMobileModelPickerDialog?: () => void; }; - - export const TryFairSidebar = ({ selectedModel, models, 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 e26a4518..03421378 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,5 +1,9 @@ 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[]; @@ -32,6 +36,34 @@ export const useTryFairParams = () => { { 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; @@ -40,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 | null) => setParams({ confidence: val }), + + isParametersDefault, + resetParameters, }; }; diff --git a/frontend/src/features/try-fair/utils/common.tsx b/frontend/src/features/try-fair/utils/common.tsx index 2736469a..00cc3a0b 100644 --- a/frontend/src/features/try-fair/utils/common.tsx +++ b/frontend/src/features/try-fair/utils/common.tsx @@ -121,4 +121,4 @@ export const getAccuracyLabel = (value: number | string | boolean): string => { return "Medium"; } return "High"; -}; \ No newline at end of file +}; From 2ac76e0c3d3a97d9ab5004eae1ab300c3d0dc0bc Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Wed, 15 Jul 2026 10:41:23 +0100 Subject: [PATCH 13/36] chore: remove high and small label --- .../src/features/try-fair/components/try-fair-sidebar.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 c0ce47fc..b9943cd6 100644 --- a/frontend/src/features/try-fair/components/try-fair-sidebar.tsx +++ b/frontend/src/features/try-fair/components/try-fair-sidebar.tsx @@ -249,10 +249,7 @@ export const TryFairSidebar = ({ -
- Low - High -
+ ); })} From 8ce9a093c1965aed21867e6c4ee28876533c427b Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Thu, 16 Jul 2026 11:40:21 +0100 Subject: [PATCH 14/36] chore: updated texts in parameters --- frontend/src/constants/ui-contents/try-fair-contents.ts | 2 +- .../src/features/try-fair/components/try-fair-sidebar.tsx | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/frontend/src/constants/ui-contents/try-fair-contents.ts b/frontend/src/constants/ui-contents/try-fair-contents.ts index 5fa4e221..68b84872 100644 --- a/frontend/src/constants/ui-contents/try-fair-contents.ts +++ b/frontend/src/constants/ui-contents/try-fair-contents.ts @@ -18,7 +18,7 @@ export const TRY_FAIR_PAGE_CONTENT: TTryFairPageContent = { parameters: { label: "Parameters", description: - "Adjust confidence and resolution to explore different prediction results.", + "Adjust size and accuracy to explore different prediction results.", learnMore: "Learn more", resolution: { label: "Size", 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 b9943cd6..122aad13 100644 --- a/frontend/src/features/try-fair/components/try-fair-sidebar.tsx +++ b/frontend/src/features/try-fair/components/try-fair-sidebar.tsx @@ -215,7 +215,7 @@ export const TryFairSidebar = ({

Accuracy

- + {getAccuracyLabel(value)}
@@ -223,7 +223,6 @@ export const TryFairSidebar = ({
- {/* Break lines at 25%, 50%, 75% */} {[25, 50, 75].map((pct) => (
-
); })} From d1ba8afd3a54ac9f2811fd61566014981f858afc Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Fri, 17 Jul 2026 08:04:31 +0100 Subject: [PATCH 15/36] feat: added new icons --- .../components/ui/icons/globe-search-icon.tsx | 41 +++++++++++++++++++ .../try-fair/components/map/try-fair-map.tsx | 14 +++++++ .../components/model-picker-modal.tsx | 13 ++++++ .../contexts/start-mapping-context.tsx | 0 4 files changed, 68 insertions(+) create mode 100644 frontend/src/components/ui/icons/globe-search-icon.tsx create mode 100644 frontend/src/features/try-fair/contexts/start-mapping-context.tsx diff --git a/frontend/src/components/ui/icons/globe-search-icon.tsx b/frontend/src/components/ui/icons/globe-search-icon.tsx new file mode 100644 index 00000000..abcb3674 --- /dev/null +++ b/frontend/src/components/ui/icons/globe-search-icon.tsx @@ -0,0 +1,41 @@ + +import { IconProps } from "@/types"; +import * as React from "react"; + +export const GlobeSearchIcon: React.FC = (props) => ( + + + + + + +); 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 f0bb7cd1..1c26e341 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,7 @@ 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"; type TryFairMapProps = { map: Map | null; @@ -175,6 +176,19 @@ export const TryFairMap = ({ {map && (
+ + + + + {/* Group 1: Zoom In, Zoom Out, Fit to bounds */}
)}
+
+ + + +
); diff --git a/frontend/src/features/try-fair/contexts/start-mapping-context.tsx b/frontend/src/features/try-fair/contexts/start-mapping-context.tsx new file mode 100644 index 00000000..e69de29b From 96cb33911c355c0017aeb7fc47be3480f6faf9f6 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Fri, 17 Jul 2026 08:07:09 +0100 Subject: [PATCH 16/36] feat: added new icons --- .../components/ui/icons/globe-search-icon.tsx | 71 +++++++++---------- .../try-fair/components/map/try-fair-map.tsx | 23 +++--- .../components/model-picker-modal.tsx | 10 +-- 3 files changed, 51 insertions(+), 53 deletions(-) diff --git a/frontend/src/components/ui/icons/globe-search-icon.tsx b/frontend/src/components/ui/icons/globe-search-icon.tsx index abcb3674..efc14b48 100644 --- a/frontend/src/components/ui/icons/globe-search-icon.tsx +++ b/frontend/src/components/ui/icons/globe-search-icon.tsx @@ -1,41 +1,40 @@ - import { IconProps } from "@/types"; import * as React from "react"; export const GlobeSearchIcon: React.FC = (props) => ( - - - - - - + + + + + + ); 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 1c26e341..7584022b 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 @@ -176,19 +176,18 @@ export const TryFairMap = ({ {map && (
- - - - - + {/* */} + + + {/* Group 1: Zoom In, Zoom Out, Fit to bounds */}
- +
); From 76f3764e843b119830b588eefc164407866a4ebc Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Sat, 18 Jul 2026 15:18:14 +0100 Subject: [PATCH 17/36] feat: added try fair imagery selector --- frontend/src/app/routes/try-fair.tsx | 14 + .../src/components/layouts/navbar/navbar.tsx | 6 +- .../shared/form/xyz-tile-server-input.tsx | 46 ++- .../src/components/ui/dropdown/dropdown.tsx | 97 +++--- .../src/components/ui/icons/expand-icon.tsx | 18 ++ frontend/src/config/env.ts | 17 +- frontend/src/config/index.ts | 52 ++++ frontend/src/enums/common.ts | 5 + .../src/features/try-fair/api/hot-imagery.ts | 149 +++++++++ .../imagery/choose-imagery-source.tsx | 50 +++ .../imagery/custom-imagery-form.tsx | 57 ++++ .../imagery/imagery-location-modal.tsx | 193 ++++++++++++ .../imagery/imagery-modal-map.layers.ts | 285 ++++++++++++++++++ .../imagery/imagery-search-panel.tsx | 163 ++++++++++ .../components/imagery/oam-imagery-map.tsx | 74 +++++ .../try-fair/components/map/try-fair-map.tsx | 7 +- .../components/model-picker-modal.tsx | 15 +- .../start-mapping/export-map-results.tsx | 59 ++++ .../contexts/start-mapping-context.tsx | 0 .../try-fair/hooks/use-imagery-modal-map.ts | 53 ++++ .../features/try-fair/hooks/use-modal-map.tsx | 50 +++ .../features/try-fair/types/imagery-types.ts | 40 +++ .../src/features/try-fair/utils/common.tsx | 6 + .../try-fair/utils/start-mapping-store.ts | 21 ++ 24 files changed, 1431 insertions(+), 46 deletions(-) create mode 100644 frontend/src/components/ui/icons/expand-icon.tsx create mode 100644 frontend/src/features/try-fair/api/hot-imagery.ts create mode 100644 frontend/src/features/try-fair/components/imagery/choose-imagery-source.tsx create mode 100644 frontend/src/features/try-fair/components/imagery/custom-imagery-form.tsx create mode 100644 frontend/src/features/try-fair/components/imagery/imagery-location-modal.tsx create mode 100644 frontend/src/features/try-fair/components/imagery/imagery-modal-map.layers.ts create mode 100644 frontend/src/features/try-fair/components/imagery/imagery-search-panel.tsx create mode 100644 frontend/src/features/try-fair/components/imagery/oam-imagery-map.tsx create mode 100644 frontend/src/features/try-fair/components/start-mapping/export-map-results.tsx delete mode 100644 frontend/src/features/try-fair/contexts/start-mapping-context.tsx create mode 100644 frontend/src/features/try-fair/hooks/use-imagery-modal-map.ts create mode 100644 frontend/src/features/try-fair/hooks/use-modal-map.tsx create mode 100644 frontend/src/features/try-fair/types/imagery-types.ts create mode 100644 frontend/src/features/try-fair/utils/start-mapping-store.ts diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index 8641fb1d..0257a1f8 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -38,10 +38,14 @@ 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"; export const TryFairPage = () => { const { map, mapContainerRef } = useMapInstance(false, false); const { isSmallViewport } = useScreenSize(); + const { showChooseLocationModal, setShowChooseLocationModal } = + useStartMappingStore(); const { getValue, setValue } = useLocalStorage(); const { setIsOpen: setIsSiteTourOpen, setCurrentStep, setSteps } = useTour(); @@ -371,6 +375,16 @@ export const TryFairPage = () => { /> + {/* Imagery/location dialog – rendered at page level */} + setShowChooseLocationModal(false)} + onApply={(_selection) => { + // TODO: handle imagery selection + setShowChooseLocationModal(false); + }} + /> +
{
)} +
{AUTH_PROVIDER === "hanko" && !IS_DEV ? ( <> @@ -170,7 +172,9 @@ export const NavBar = () => { ) : isAuthenticated ? ( <> - {isAuthenticated && } + {isAuthenticated && !isTryFairPage && } + {isAuthenticated && isTryFairPage && } + ) : ( 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 1d92e294..83ccac0a 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 ( -
+
setQuery(e.target.value)} + placeholder="Search place to map" + className="flex-1 px-3 py-2.5 text-sm text-dark outline-none min-w-0" + /> + + + + {/* Images within the selected grid cell */} + {cellSelected && ( +
+
+

+ {cellCount} image{cellCount === 1 ? "" : "s"} within selected grid + square +

+ {loading && } +
+ +
+ {!loading && images.length === 0 ? ( +

+ No imagery available in this grid square. +

+ ) : ( +
+ {images.map((item) => ( + + onSelect(selectedItem?.id === clicked.id ? null : clicked) + } + /> + ))} +
+ )} +
+
+ )} + + ); +}; 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 00000000..dbb7ed88 --- /dev/null +++ b/frontend/src/features/try-fair/components/imagery/oam-imagery-map.tsx @@ -0,0 +1,74 @@ +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"; + +export type { SelectedCell }; + +type Props = { + /** 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; +}; + +/** + * The OpenAerialMap tab's map: the imagery.hotosm.org coverage grid. Clicking a + * grid cell highlights it and reports it upward so the dialog can list the + * imagery inside; the selected image is previewed as raster tiles on top. + * + * MapLibre plumbing lives in ./imagery-modal-map.layers.ts, so each effect + * below reads as one action. + */ +export const OamImageryMap = ({ + selectedItem, + onCellSelect, + onMapReady, +}: Props) => { + const { map, mapContainerRef } = useImageryModalMap(); + + // Latest callback read inside the long-lived click handler, kept in a ref so + // the handler is bound once without re-binding on every render. + const onCellSelectRef = useRef(onCellSelect); + onCellSelectRef.current = onCellSelect; + + // Once ready: add the density grid + cell highlight, and wire click-to-select. + useEffect(() => { + if (!map) return; + addImageryLayers(map); + onMapReady?.(map); + + const handleClick = (e: MapMouseEvent) => { + const hit = readCellAt(map, e.point); + highlightCell(map, hit?.geometry ?? null); + onCellSelectRef.current(hit?.cell ?? null); + }; + map.on("click", handleClick); + return () => { + map.off("click", handleClick); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); + + // 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 7584022b..ebac5ed9 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 @@ -19,6 +19,7 @@ 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"; type TryFairMapProps = { map: Map | null; @@ -61,6 +62,7 @@ export const TryFairMap = ({ onHelp, }: TryFairMapProps) => { const { isSmallViewport } = useScreenSize(); + const { setShowChooseLocationModal } = useStartMappingStore(); const [choroplethBuckets, setChoroplethBuckets] = useState< ChoroplethBucket[] | null >(null); @@ -179,12 +181,11 @@ export const TryFairMap = ({ 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 9e02b511..1b16893a 100644 --- a/frontend/src/features/try-fair/components/model-picker-modal.tsx +++ b/frontend/src/features/try-fair/components/model-picker-modal.tsx @@ -6,6 +6,7 @@ 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"; type ModelPickerProps = { selectedModel: BaseModelStacItem | null; @@ -130,6 +131,11 @@ export const ModelPickerContent = ({ models: BaseModelStacItem[]; }) => (
+
+

Default Locations

+
+
+
{models.length > 0 ? ( models.map((model) => { @@ -181,11 +187,16 @@ export const ModelPickerContent = ({ )}
- - + } + /> + ); +}; + +export default ExportMapResults; diff --git a/frontend/src/features/try-fair/contexts/start-mapping-context.tsx b/frontend/src/features/try-fair/contexts/start-mapping-context.tsx deleted file mode 100644 index e69de29b..00000000 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 00000000..2215e2b8 --- /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-modal-map.tsx b/frontend/src/features/try-fair/hooks/use-modal-map.tsx new file mode 100644 index 00000000..c6d4483f --- /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/types/imagery-types.ts b/frontend/src/features/try-fair/types/imagery-types.ts new file mode 100644 index 00000000..8cff29e9 --- /dev/null +++ b/frontend/src/features/try-fair/types/imagery-types.ts @@ -0,0 +1,40 @@ +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; +}; +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; + }; diff --git a/frontend/src/features/try-fair/utils/common.tsx b/frontend/src/features/try-fair/utils/common.tsx index 00cc3a0b..3367b045 100644 --- a/frontend/src/features/try-fair/utils/common.tsx +++ b/frontend/src/features/try-fair/utils/common.tsx @@ -5,6 +5,7 @@ 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"; // This is the default zoom level to start mapping. @@ -122,3 +123,8 @@ export const getAccuracyLabel = (value: number | string | boolean): string => { } return "High"; }; + +export const IMAGERY_SOURCES: { value: ImagerySource; label: string }[] = [ + { value: ImagerySource.OPEN_AERIAL_MAP, label: "OpenAerialMap" }, + { value: ImagerySource.CUSTOM, label: "Custom Imagery" }, +]; 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 00000000..2a4805b3 --- /dev/null +++ b/frontend/src/features/try-fair/utils/start-mapping-store.ts @@ -0,0 +1,21 @@ +// store/zoomStore.ts +import { create } from "zustand"; + +type IStartMappingStore = { + imagery: string; + setImagery: (imagery: string) => void; + downloadType: string; + setDownloadType: (imagery: string) => void; + showChooseLocationModal: boolean; + setShowChooseLocationModal: (show: boolean) => void; +}; + +export const useStartMappingStore = create((set) => ({ + imagery: "", + setImagery: (imagery) => set({ imagery }), + downloadType: "", + setDownloadType: (downloadType) => set({ downloadType }), + showChooseLocationModal: false, + setShowChooseLocationModal: (showChooseLocationModal) => + set({ showChooseLocationModal }), +})); From 6eb25d535ec14e81e0228e4fad58979b1dfa0263 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Mon, 20 Jul 2026 14:19:47 +0100 Subject: [PATCH 18/36] feat: added signin prompt modal --- frontend/src/app/routes/try-fair.tsx | 9 +- .../shared/modals/confirmation-modal.tsx | 7 +- .../src/components/ui/icons/caution-icon.tsx | 24 +++ .../imagery/imagery-location-modal.tsx | 10 +- .../try-fair/components/map/try-fair-map.tsx | 12 +- .../components/modals/sign-in-prompt.tsx | 54 +++++++ .../components/model-picker-modal.tsx | 153 +++++++++--------- .../try-fair/utils/start-mapping-store.ts | 5 + .../publish-prediction-flow.tsx | 1 + 9 files changed, 193 insertions(+), 82 deletions(-) create mode 100644 frontend/src/components/ui/icons/caution-icon.tsx create mode 100644 frontend/src/features/try-fair/components/modals/sign-in-prompt.tsx diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index 0257a1f8..0c3f84d4 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -40,11 +40,12 @@ 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"; export const TryFairPage = () => { const { map, mapContainerRef } = useMapInstance(false, false); const { isSmallViewport } = useScreenSize(); - const { showChooseLocationModal, setShowChooseLocationModal } = + const { showChooseLocationModal, setShowChooseLocationModal, showSigninModal, setShowSigninModal } = useStartMappingStore(); const { getValue, setValue } = useLocalStorage(); const { setIsOpen: setIsSiteTourOpen, setCurrentStep, setSteps } = useTour(); @@ -384,6 +385,12 @@ export const TryFairPage = () => { setShowChooseLocationModal(false); }} /> + setShowSigninModal(false)} + /> + + {/* Signin Prompt */}
diff --git a/frontend/src/components/shared/modals/confirmation-modal.tsx b/frontend/src/components/shared/modals/confirmation-modal.tsx index 9d277ee6..885d5686 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,16 @@ export const ConfirmationModal = ({

{message}

-
diff --git a/frontend/src/components/ui/icons/caution-icon.tsx b/frontend/src/components/ui/icons/caution-icon.tsx new file mode 100644 index 00000000..c532c5e7 --- /dev/null +++ b/frontend/src/components/ui/icons/caution-icon.tsx @@ -0,0 +1,24 @@ +import { IconProps, ShoelaceSlotProps } from "@/types"; +import React from "react"; + +export const CautionIcon: React.FC = (props) => ( + + + + +); 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 index 0e9a5425..5f9bedba 100644 --- a/frontend/src/features/try-fair/components/imagery/imagery-location-modal.tsx +++ b/frontend/src/features/try-fair/components/imagery/imagery-location-modal.tsx @@ -5,17 +5,17 @@ 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 "./custom-imagery-form"; -import { OamImageryMap, SelectedCell } from "./oam-imagery-map"; +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 { geocodeLocation, getImageryTileUrl, OAMImageryItem, searchImagery, -} from "../../api/hot-imagery"; +} from "@/features/try-fair/api/hot-imagery"; import { Button } from "@/components/ui/button"; -import { OAMImageryPanel } from "./imagery-search-panel"; -import { ImagerySelection } from "../../types/imagery-types"; +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 { MapIcon } from "@/components/ui/icons"; 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 ebac5ed9..2d379c3e 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 @@ -20,6 +20,7 @@ import { TryFairDownloadButton } from "@/features/try-fair/components/map/try-fa 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; @@ -62,7 +63,8 @@ export const TryFairMap = ({ onHelp, }: TryFairMapProps) => { const { isSmallViewport } = useScreenSize(); - const { setShowChooseLocationModal } = useStartMappingStore(); + const { setShowChooseLocationModal, setShowSigninModal } = useStartMappingStore(); + const {isAuthenticated} = useAuth() const [choroplethBuckets, setChoroplethBuckets] = useState< ChoroplethBucket[] | null >(null); @@ -181,7 +183,13 @@ export const TryFairMap = ({ + +
+
+ + ); +}; 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 1b16893a..daac503a 100644 --- a/frontend/src/features/try-fair/components/model-picker-modal.tsx +++ b/frontend/src/features/try-fair/components/model-picker-modal.tsx @@ -7,6 +7,7 @@ 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"; type ModelPickerProps = { selectedModel: BaseModelStacItem | null; @@ -74,9 +75,8 @@ export const ModelPicker: React.FC = ({
); @@ -129,78 +129,85 @@ export const ModelPickerContent = ({ selectedModel: BaseModelStacItem | null; onSelect: (model: BaseModelStacItem) => void; models: BaseModelStacItem[]; -}) => ( -
-
-

Default Locations

-
-
+}) => { + const { isAuthenticated } = useAuth() + const { setShowChooseLocationModal, setShowSigninModal } = useStartMappingStore(); -
- {models.length > 0 ? ( - models.map((model) => { - const isSelected = selectedModel?.id === model.id; - // const tasks = model.properties["mlm:tasks"] ?? []; - return ( -
-

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

-

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

- - - ); - }) - ) : ( -
-

- No models available -

-

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

-
- )} -
-
- + > +
+

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

- + + {isSelected && ( + + )} + +
+

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

+

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

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

+ No models available +

+

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

+
+ )} +
+
+ + + +
-
-); + ); +} diff --git a/frontend/src/features/try-fair/utils/start-mapping-store.ts b/frontend/src/features/try-fair/utils/start-mapping-store.ts index 2a4805b3..b88de819 100644 --- a/frontend/src/features/try-fair/utils/start-mapping-store.ts +++ b/frontend/src/features/try-fair/utils/start-mapping-store.ts @@ -8,6 +8,8 @@ type IStartMappingStore = { setDownloadType: (imagery: string) => void; showChooseLocationModal: boolean; setShowChooseLocationModal: (show: boolean) => void; + showSigninModal: boolean; + setShowSigninModal: (show: boolean) => void; }; export const useStartMappingStore = create((set) => ({ @@ -18,4 +20,7 @@ export const useStartMappingStore = create((set) => ({ showChooseLocationModal: false, setShowChooseLocationModal: (showChooseLocationModal) => set({ showChooseLocationModal }), + showSigninModal: false, + setShowSigninModal: (showSigninModal) => + set({ showSigninModal }), })); 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 7e92d80d..cdaa774b 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" From bcef51ec9532710a7d0f535462a6b0a0d56028db Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Mon, 20 Jul 2026 14:20:46 +0100 Subject: [PATCH 19/36] feat: added signin prompt modal --- frontend/src/app/routes/try-fair.tsx | 12 ++++--- .../shared/modals/confirmation-modal.tsx | 10 +++--- .../src/components/ui/icons/caution-icon.tsx | 31 ++++++++----------- .../imagery/imagery-location-modal.tsx | 10 ++++-- .../try-fair/components/map/try-fair-map.tsx | 13 ++++---- .../components/modals/sign-in-prompt.tsx | 2 +- .../components/model-picker-modal.tsx | 26 +++++++++------- .../try-fair/utils/start-mapping-store.ts | 3 +- 8 files changed, 57 insertions(+), 50 deletions(-) diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index 0c3f84d4..c17b330d 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -45,8 +45,12 @@ import { SignInPromptDialog } from "@/features/try-fair/components/modals/sign-i export const TryFairPage = () => { const { map, mapContainerRef } = useMapInstance(false, false); const { isSmallViewport } = useScreenSize(); - const { showChooseLocationModal, setShowChooseLocationModal, showSigninModal, setShowSigninModal } = - useStartMappingStore(); + const { + showChooseLocationModal, + setShowChooseLocationModal, + showSigninModal, + setShowSigninModal, + } = useStartMappingStore(); const { getValue, setValue } = useLocalStorage(); const { setIsOpen: setIsSiteTourOpen, setCurrentStep, setSteps } = useTour(); @@ -386,8 +390,8 @@ export const TryFairPage = () => { }} /> setShowSigninModal(false)} + isOpened={showSigninModal} + closeDialog={() => setShowSigninModal(false)} /> {/* Signin Prompt */} diff --git a/frontend/src/components/shared/modals/confirmation-modal.tsx b/frontend/src/components/shared/modals/confirmation-modal.tsx index 885d5686..7afb2c13 100644 --- a/frontend/src/components/shared/modals/confirmation-modal.tsx +++ b/frontend/src/components/shared/modals/confirmation-modal.tsx @@ -12,7 +12,7 @@ type ConfirmationModalProps = { isConfirming?: boolean; confirmLabel?: string; cancelLabel?: string; - rounded?: boolean + rounded?: boolean; }; export const ConfirmationModal = ({ @@ -24,7 +24,7 @@ export const ConfirmationModal = ({ isConfirming = false, confirmLabel = "Confirm", cancelLabel = "Cancel", - rounded = false + rounded = false, }: ConfirmationModalProps) => { const { isMobile } = useScreenSize(); @@ -43,16 +43,14 @@ export const ConfirmationModal = ({

{message}

-
diff --git a/frontend/src/components/ui/icons/caution-icon.tsx b/frontend/src/components/ui/icons/caution-icon.tsx index c532c5e7..cb2b8dc9 100644 --- a/frontend/src/components/ui/icons/caution-icon.tsx +++ b/frontend/src/components/ui/icons/caution-icon.tsx @@ -3,22 +3,17 @@ import React from "react"; export const CautionIcon: React.FC = (props) => ( - - - + xmlns="http://www.w3.org/2000/svg" + width="48" + height="48" + viewBox="0 0 48 48" + fill="none" + {...props} + > + + + ); 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 index 5f9bedba..7b18b410 100644 --- a/frontend/src/features/try-fair/components/imagery/imagery-location-modal.tsx +++ b/frontend/src/features/try-fair/components/imagery/imagery-location-modal.tsx @@ -5,8 +5,14 @@ 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 { + AppliedCustomImagery, + CustomImageryForm, +} from "@/features/try-fair/components/imagery/custom-imagery-form"; +import { + OamImageryMap, + SelectedCell, +} from "@/features/try-fair/components/imagery/oam-imagery-map"; import { geocodeLocation, getImageryTileUrl, 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 2d379c3e..b792b988 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 @@ -63,8 +63,9 @@ export const TryFairMap = ({ onHelp, }: TryFairMapProps) => { const { isSmallViewport } = useScreenSize(); - const { setShowChooseLocationModal, setShowSigninModal } = useStartMappingStore(); - const {isAuthenticated} = useAuth() + const { setShowChooseLocationModal, setShowSigninModal } = + useStartMappingStore(); + const { isAuthenticated } = useAuth(); const [choroplethBuckets, setChoroplethBuckets] = useState< ChoroplethBucket[] | null >(null); @@ -184,10 +185,10 @@ export const TryFairMap = ({

- You must sign in to change the imagery. + 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 daac503a..2cc456fc 100644 --- a/frontend/src/features/try-fair/components/model-picker-modal.tsx +++ b/frontend/src/features/try-fair/components/model-picker-modal.tsx @@ -75,8 +75,9 @@ export const ModelPicker: React.FC = ({
); @@ -130,8 +131,9 @@ export const ModelPickerContent = ({ onSelect: (model: BaseModelStacItem) => void; models: BaseModelStacItem[]; }) => { - const { isAuthenticated } = useAuth() - const { setShowChooseLocationModal, setShowSigninModal } = useStartMappingStore(); + const { isAuthenticated } = useAuth(); + const { setShowChooseLocationModal, setShowSigninModal } = + useStartMappingStore(); return (
@@ -150,8 +152,9 @@ export const ModelPickerContent = ({ key={model.id} type="button" onClick={() => onSelect(model)} - className={`text-left p-3 bg-frosted-blue rounded-lg transition-colors ${isSelected ? "border-primary border-2" : "" - }`} + className={`text-left p-3 bg-frosted-blue rounded-lg transition-colors ${ + isSelected ? "border-primary border-2" : "" + }`} >

@@ -159,8 +162,9 @@ export const ModelPickerContent = ({

{isSelected && ( @@ -197,9 +201,9 @@ export const ModelPickerContent = ({ className="flex gap-2 items-center" onClick={() => { if (isAuthenticated) { - setShowChooseLocationModal(true) + setShowChooseLocationModal(true); } else { - setShowSigninModal(true) + setShowSigninModal(true); } }} > @@ -210,4 +214,4 @@ export const ModelPickerContent = ({
); -} +}; diff --git a/frontend/src/features/try-fair/utils/start-mapping-store.ts b/frontend/src/features/try-fair/utils/start-mapping-store.ts index b88de819..3eb3dbd5 100644 --- a/frontend/src/features/try-fair/utils/start-mapping-store.ts +++ b/frontend/src/features/try-fair/utils/start-mapping-store.ts @@ -21,6 +21,5 @@ export const useStartMappingStore = create((set) => ({ setShowChooseLocationModal: (showChooseLocationModal) => set({ showChooseLocationModal }), showSigninModal: false, - setShowSigninModal: (showSigninModal) => - set({ showSigninModal }), + setShowSigninModal: (showSigninModal) => set({ showSigninModal }), })); From 1dbe1def4ea9278cadf3bbd5e8ee0456c00c2716 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 21 Jul 2026 10:39:45 +0100 Subject: [PATCH 20/36] feat: added imagery selector --- frontend/src/app/routes/try-fair.tsx | 16 +- .../components/ui/icons/globe-search-icon.tsx | 8 +- .../src/features/try-fair/api/hot-imagery.ts | 55 +++--- .../imagery/imagery-location-modal.tsx | 83 ++++---- .../imagery/imagery-modal-map.layers.ts | 90 ++++----- .../imagery/imagery-search-panel.tsx | 184 ++++++++++++------ .../components/imagery/location-search.tsx | 149 ++++++++++++++ .../components/imagery/oam-imagery-map.tsx | 25 ++- .../components/model-picker-modal.tsx | 64 ++++-- .../features/try-fair/types/imagery-types.ts | 3 + .../src/features/try-fair/utils/common.tsx | 40 ++++ .../try-fair/utils/start-mapping-store.ts | 14 +- 12 files changed, 529 insertions(+), 202 deletions(-) create mode 100644 frontend/src/features/try-fair/components/imagery/location-search.tsx diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index c17b330d..c8df80cf 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -50,6 +50,10 @@ export const TryFairPage = () => { setShowChooseLocationModal, showSigninModal, setShowSigninModal, + currentModelType, + setCurrentModelType, + setSeletedImagery, + selectedImagery } = useStartMappingStore(); const { getValue, setValue } = useLocalStorage(); const { setIsOpen: setIsSiteTourOpen, setCurrentStep, setSteps } = useTour(); @@ -135,11 +139,12 @@ 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, @@ -150,6 +155,7 @@ export const TryFairPage = () => { // Site tour trigger logic based on map interactions and prediction state. const GRID_ZOOM_IN_DURATION = 1500; + // Zoom to grid and fit the map to the grid bbox. const handleZoomToGrid = useCallback(() => { if (map && latestBBox) { @@ -231,6 +237,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 @@ -366,7 +373,7 @@ export const TryFairPage = () => { {/* Model picker dialog – rendered at page level so it's not trapped inside MobileDrawer */} @@ -386,6 +393,9 @@ export const TryFairPage = () => { closeDialog={() => setShowChooseLocationModal(false)} onApply={(_selection) => { // TODO: handle imagery selection + setCurrentModelType("imagery") + setSeletedImagery(_selection) + setShowChooseLocationModal(false); }} /> diff --git a/frontend/src/components/ui/icons/globe-search-icon.tsx b/frontend/src/components/ui/icons/globe-search-icon.tsx index efc14b48..df29fa33 100644 --- a/frontend/src/components/ui/icons/globe-search-icon.tsx +++ b/frontend/src/components/ui/icons/globe-search-icon.tsx @@ -12,27 +12,27 @@ export const GlobeSearchIcon: React.FC = (props) => ( > diff --git a/frontend/src/features/try-fair/api/hot-imagery.ts b/frontend/src/features/try-fair/api/hot-imagery.ts index 7ac9f7f5..9701f4e3 100644 --- a/frontend/src/features/try-fair/api/hot-imagery.ts +++ b/frontend/src/features/try-fair/api/hot-imagery.ts @@ -59,9 +59,7 @@ type StacSearchResponse = { const toImageryItem = (feature: StacImageryFeature): OAMImageryItem => { const { properties, assets } = feature; - const assetName = assets.visual - ? "visual" - : (Object.keys(assets)[0] ?? "visual"); + const assetName = assets.visual ? "visual" : (Object.keys(assets)[0] ?? "visual"); return { id: feature.id, bbox: feature.bbox, @@ -117,33 +115,34 @@ export const searchImagery = async ({ return data.features.map(toImageryItem); }; -/** - * Geocode a free-text location (e.g. "Brazil") via Nominatim and return its - * bounding box, suitable for map.fitBounds. - */ -export const geocodeLocation = async ( - query: string, - signal?: AbortSignal, -): Promise => { - const { data } = await axios.get< - Array<{ - display_name: string; - boundingbox: string[]; - lat: string; - lon: string; - }> - >(`${NOMINATIM_API_URL}/search`, { - params: { format: "json", q: query, limit: 1 }, - signal, - }); - - const first = data?.[0]; - if (!first) return null; +type NominatimResult = { + display_name: string; + boundingbox: string[]; + lat: string; + lon: string; +}; - const [south, north, west, east] = first.boundingbox.map(parseFloat); +const toGeocodeResult = (r: NominatimResult): GeocodeResult => { + const [south, north, west, east] = r.boundingbox.map(parseFloat); return { - displayName: first.display_name, + displayName: r.display_name, bbox: [west, south, east, north], - center: [parseFloat(first.lon), parseFloat(first.lat)], + center: [parseFloat(r.lon), parseFloat(r.lat)], }; }; + +/** + * Autocomplete suggestions for a location query (up to `limit`), each with a + * bounding box for map.fitBounds. Powers the search box's suggestion dropdown. + */ +export const geocodeSuggestions = async ( + query: string, + limit = 5, + signal?: AbortSignal, +): Promise => { + const { data } = await axios.get( + `${NOMINATIM_API_URL}/search`, + { params: { format: "json", q: query, limit }, signal }, + ); + return (data ?? []).map(toGeocodeResult); +}; diff --git a/frontend/src/features/try-fair/components/imagery/imagery-location-modal.tsx b/frontend/src/features/try-fair/components/imagery/imagery-location-modal.tsx index 7b18b410..e3e11a1c 100644 --- a/frontend/src/features/try-fair/components/imagery/imagery-location-modal.tsx +++ b/frontend/src/features/try-fair/components/imagery/imagery-location-modal.tsx @@ -14,7 +14,7 @@ import { SelectedCell, } from "@/features/try-fair/components/imagery/oam-imagery-map"; import { - geocodeLocation, + GeocodeResult, getImageryTileUrl, OAMImageryItem, searchImagery, @@ -24,6 +24,9 @@ import { OAMImageryPanel } from "@/features/try-fair/components/imagery/imagery- import { ImagerySelection } from "@/features/try-fair/types/imagery-types"; import { Divider } from "@/components/ui/divider"; import { MapIcon } from "@/components/ui/icons"; +import { cn } from "@/utils"; +import { LocationSearch } from "./location-search"; +import { ToolTip } from "@/components/ui/tooltip"; export enum ImagerySource { OPEN_AERIAL_MAP = "openAerialMap", @@ -51,10 +54,8 @@ export const ImageryLocationDialog = ({ const [cellImages, setCellImages] = useState([]); const [cellLoading, setCellLoading] = useState(false); const [selectedItem, setSelectedItem] = useState(null); - const [searching, setSearching] = useState(false); const [appliedCustomImagery, setAppliedCustomImagery] = useState(null); - const mapRef = useRef(null); const searchAbortRef = useRef(null); @@ -102,63 +103,75 @@ export const ImageryLocationDialog = ({ bounds: imagery.bounds, }); }; - - const handleSearch = async (query: string) => { - if (!mapRef.current || searching) return; - setSearching(true); - try { - const result = await geocodeLocation(query); - if (result) { - mapRef.current.fitBounds(result.bbox as BBOX, { padding: 40 }); - } - } finally { - setSearching(false); - } + // 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. + const handleClearSearch = () => { + 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; + }} + /> +
{isOAM ? ( <> - { - mapRef.current = map; - }} - /> +
+ +
- setSelectedCell(null)} /> -
- + +
) : ( 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 index 32d8317c..63d830a9 100644 --- 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 @@ -8,8 +8,8 @@ * (components) is separated from the "how" (this file). * * Layer stacking, bottom β†’ top: - * basemap β†’ density grid β†’ selected-cell highlight β†’ imagery/custom preview - * Previews are added last so they sit above the grid. + * basemap β†’ density grid β†’ count labels β†’ selected-cell highlight β†’ preview + * The image preview is added last so it sits above the grid. */ import maplibregl, { GeoJSONSource, @@ -24,10 +24,7 @@ import { HOT_IMAGERY_DENSITY_SOURCE_LAYER, MAX_ZOOM_LEVEL, } from "@/config"; -import { - getImageryTileUrl, - OAMImageryItem, -} from "@/features/try-fair/api/hot-imagery"; +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. @@ -37,23 +34,21 @@ const SOURCES = { density: "modal-oam-density", cell: "modal-selected-cell", imagery: "modal-oam-selected", - custom: "modal-custom-imagery", } 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", - custom: "modal-custom-imagery-layer", } as const; // ── Styling ─────────────────────────────────────────────────────────────────── // Blue choropleth ramp keyed on each cell's image `count`: pale where sparse, -// deep where dense. Matches the imagery-coverage design (shaded cells, no -// number labels). Pairs are [count, color], fed to an `interpolate`. +// deep where dense. Pairs are [count, color], fed to an `interpolate`. const DENSITY_COLOR_RAMP: (string | number)[] = [ 1, "#cbdbe3", @@ -74,8 +69,15 @@ const EMPTY_COLLECTION: GeoJSON.FeatureCollection = { // ── Types ───────────────────────────────────────────────────────────────────── -/** A density grid cell the user selected: its image extent and image count. */ -export type SelectedCell = { bbox: BBOX; count: number }; +/** + * 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 = { @@ -86,8 +88,6 @@ type DensityCellProps = { bboxN?: number; }; -export type CustomScheme = "xyz" | "tms"; - // ── Map creation ────────────────────────────────────────────────────────────── // Register the pmtiles:// scheme handler exactly once for the app lifetime. @@ -109,6 +109,9 @@ const registerPmtilesProtocol = () => { 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", @@ -172,6 +175,28 @@ export const addImageryLayers = (map: MapLibreMap): void => { }, }); + // 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, @@ -197,24 +222,21 @@ export const addImageryLayers = (map: MapLibreMap): void => { /** * The density cell under a click point, or null if the click missed the grid. - * Returns the parsed {@link SelectedCell} plus the cell geometry for the - * highlight. `bboxW/S/E/N` (the aggregated image extent) is what the picker - * searches, not the wider cell square. + * `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, -): { cell: SelectedCell; geometry: GeoJSON.Geometry } | null => { +): 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 { - cell: { - bbox: [p.bboxW, p.bboxS!, p.bboxE!, p.bboxN!], - count: p.count, - }, + bbox: [p.bboxW, p.bboxS!, p.bboxE!, p.bboxN!], + count: p.count, geometry: hit.geometry, }; }; @@ -227,10 +249,7 @@ export const highlightCell = ( const src = map.getSource(SOURCES.cell) as GeoJSONSource | undefined; src?.setData( geometry - ? { - type: "FeatureCollection", - features: [{ type: "Feature", properties: {}, geometry }], - } + ? { type: "FeatureCollection", features: [{ type: "Feature", properties: {}, geometry }] } : EMPTY_COLLECTION, ); }; @@ -264,22 +283,3 @@ export const showImageryPreview = ( export const clearImageryPreview = (map: MapLibreMap): void => removeLayerAndSource(map, LAYERS.imagery, SOURCES.imagery); - -/** Preview a custom XYZ/TMS tile server over the basemap. */ -export const showCustomPreview = ( - map: MapLibreMap, - tileUrl: string, - scheme: CustomScheme, -): void => { - removeLayerAndSource(map, LAYERS.custom, SOURCES.custom); - map.addSource(SOURCES.custom, { - type: "raster", - tiles: [tileUrl], - tileSize: 256, - scheme, - }); - map.addLayer({ id: LAYERS.custom, type: "raster", source: SOURCES.custom }); -}; - -export const clearCustomPreview = (map: MapLibreMap): void => - removeLayerAndSource(map, LAYERS.custom, SOURCES.custom); 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 index 552659ac..d3798589 100644 --- a/frontend/src/features/try-fair/components/imagery/imagery-search-panel.tsx +++ b/frontend/src/features/try-fair/components/imagery/imagery-search-panel.tsx @@ -1,9 +1,13 @@ -import { useState } from "react"; -import { cn } from "@/utils"; -import { SearchIcon } from "@/components/ui/icons"; +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"; const formatGsd = (gsd: number | null): string => { if (gsd == null) return "N/A"; @@ -11,8 +15,40 @@ const formatGsd = (gsd: number | null): string => { }; const formatDate = (iso: string | null): string => - iso ? iso.slice(0, 10) : "Unknown date"; + 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 index dbb7ed88..91369f2b 100644 --- a/frontend/src/features/try-fair/components/imagery/oam-imagery-map.tsx +++ b/frontend/src/features/try-fair/components/imagery/oam-imagery-map.tsx @@ -15,6 +15,9 @@ import { 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). */ @@ -24,14 +27,15 @@ type Props = { }; /** - * The OpenAerialMap tab's map: the imagery.hotosm.org coverage grid. Clicking a - * grid cell highlights it and reports it upward so the dialog can list the - * imagery inside; the selected image is previewed as raster tiles on top. + * 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, @@ -43,16 +47,14 @@ export const OamImageryMap = ({ const onCellSelectRef = useRef(onCellSelect); onCellSelectRef.current = onCellSelect; - // Once ready: add the density grid + cell highlight, and wire click-to-select. + // Once ready: add the density grid + labels, and wire click-to-select. useEffect(() => { if (!map) return; addImageryLayers(map); onMapReady?.(map); const handleClick = (e: MapMouseEvent) => { - const hit = readCellAt(map, e.point); - highlightCell(map, hit?.geometry ?? null); - onCellSelectRef.current(hit?.cell ?? null); + onCellSelectRef.current(readCellAt(map, e.point)); }; map.on("click", handleClick); return () => { @@ -61,6 +63,11 @@ export const OamImageryMap = ({ // 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; @@ -68,7 +75,5 @@ export const OamImageryMap = ({ else clearImageryPreview(map); }, [map, selectedItem]); - return ( - - ); + return ; }; 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 2cc456fc..bac2edb0 100644 --- a/frontend/src/features/try-fair/components/model-picker-modal.tsx +++ b/frontend/src/features/try-fair/components/model-picker-modal.tsx @@ -8,6 +8,7 @@ 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; @@ -75,9 +76,8 @@ export const ModelPicker: React.FC = ({
); @@ -132,11 +132,49 @@ export const ModelPickerContent = ({ models: BaseModelStacItem[]; }) => { const { isAuthenticated } = useAuth(); - const { setShowChooseLocationModal, setShowSigninModal } = + const { setShowChooseLocationModal, setShowSigninModal, currentModelType, selectedImagery } = useStartMappingStore(); - +console.log(selectedImagery?.source === ImagerySource.OPEN_AERIAL_MAP ? selectedImagery.item : undefined) return (
+ +{ + selectedImagery && selectedImagery?.source === ImagerySource.OPEN_AERIAL_MAP && ( + + ) +}

Default Locations

@@ -152,9 +190,8 @@ export const ModelPickerContent = ({ key={model.id} type="button" onClick={() => onSelect(model)} - className={`text-left p-3 bg-frosted-blue rounded-lg transition-colors ${ - isSelected ? "border-primary border-2" : "" - }`} + className={`text-left p-3 bg-frosted-blue rounded-lg transition-colors ${isSelected ? "border-primary border-2" : "" + }`} >

@@ -162,9 +199,8 @@ export const ModelPickerContent = ({

{isSelected && ( @@ -207,9 +243,9 @@ export const ModelPickerContent = ({ } }} > - - Map a different location - + + Map a different location +
diff --git a/frontend/src/features/try-fair/types/imagery-types.ts b/frontend/src/features/try-fair/types/imagery-types.ts index 8cff29e9..06d1d6aa 100644 --- a/frontend/src/features/try-fair/types/imagery-types.ts +++ b/frontend/src/features/try-fair/types/imagery-types.ts @@ -24,6 +24,7 @@ export type AppliedCustomImagery = { tileServiceType: TileServiceType; bounds: BBOX | null; }; +/** The imagery choice emitted by the imagery/location dialog on Apply. */ export type ImagerySelection = | { source: ImagerySource.OPEN_AERIAL_MAP; @@ -38,3 +39,5 @@ export type ImagerySelection = 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 3367b045..ecfcee93 100644 --- a/frontend/src/features/try-fair/utils/common.tsx +++ b/frontend/src/features/try-fair/utils/common.tsx @@ -6,6 +6,7 @@ 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"; // This is the default zoom level to start mapping. @@ -128,3 +129,42 @@ 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; +}; \ No newline at end of file diff --git a/frontend/src/features/try-fair/utils/start-mapping-store.ts b/frontend/src/features/try-fair/utils/start-mapping-store.ts index 3eb3dbd5..f622b7e6 100644 --- a/frontend/src/features/try-fair/utils/start-mapping-store.ts +++ b/frontend/src/features/try-fair/utils/start-mapping-store.ts @@ -1,20 +1,24 @@ // store/zoomStore.ts +import { ImagerySelection } from "@/features/try-fair/types/imagery-types"; import { create } from "zustand"; type IStartMappingStore = { - imagery: string; - setImagery: (imagery: string) => void; + 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; + currentModelType: "demo" | "imagery"; + setCurrentModelType: (type: "demo" | "imagery") => void; }; export const useStartMappingStore = create((set) => ({ - imagery: "", - setImagery: (imagery) => set({ imagery }), + selectedImagery: null, + setSeletedImagery: (selectedImagery) => set({ selectedImagery }), downloadType: "", setDownloadType: (downloadType) => set({ downloadType }), showChooseLocationModal: false, @@ -22,4 +26,6 @@ export const useStartMappingStore = create((set) => ({ set({ showChooseLocationModal }), showSigninModal: false, setShowSigninModal: (showSigninModal) => set({ showSigninModal }), + currentModelType: "demo", + setCurrentModelType: (currentModelType) => set({currentModelType}) })); From e080c305c58b9cfdb44dcefa64a1d4dc4aa83736 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 21 Jul 2026 10:40:55 +0100 Subject: [PATCH 21/36] feat: added imagery selector --- frontend/src/app/routes/try-fair.tsx | 14 +- .../src/features/try-fair/api/hot-imagery.ts | 4 +- .../imagery/imagery-location-modal.tsx | 15 +- .../imagery/imagery-modal-map.layers.ts | 10 +- .../imagery/imagery-search-panel.tsx | 131 +++++----- .../components/imagery/location-search.tsx | 240 +++++++++--------- .../components/imagery/oam-imagery-map.tsx | 4 +- .../components/model-picker-modal.tsx | 92 ++++--- .../src/features/try-fair/utils/common.tsx | 15 +- .../try-fair/utils/start-mapping-store.ts | 4 +- 10 files changed, 279 insertions(+), 250 deletions(-) diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index c8df80cf..0c55ea38 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -53,7 +53,7 @@ export const TryFairPage = () => { currentModelType, setCurrentModelType, setSeletedImagery, - selectedImagery + selectedImagery, } = useStartMappingStore(); const { getValue, setValue } = useLocalStorage(); const { setIsOpen: setIsSiteTourOpen, setCurrentStep, setSteps } = useTour(); @@ -139,7 +139,10 @@ export const TryFairPage = () => { ); const tileServiceUrl = useMemo(() => { - const modelImagery = currentModelType === "demo" ? selectedModel?.properties["fair:source_imagery"] : selectedImagery?.tileUrl + 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; @@ -155,7 +158,6 @@ export const TryFairPage = () => { // Site tour trigger logic based on map interactions and prediction state. const GRID_ZOOM_IN_DURATION = 1500; - // Zoom to grid and fit the map to the grid bbox. const handleZoomToGrid = useCallback(() => { if (map && latestBBox) { @@ -237,7 +239,7 @@ export const TryFairPage = () => { const handleSelectModel = (model: BaseModelStacItem) => { setModelId(model.id); - setCurrentModelType("demo") + setCurrentModelType("demo"); setResolution(TryFairResolution.MID); // Reset confidence threshold to model's spec default if available @@ -393,8 +395,8 @@ export const TryFairPage = () => { closeDialog={() => setShowChooseLocationModal(false)} onApply={(_selection) => { // TODO: handle imagery selection - setCurrentModelType("imagery") - setSeletedImagery(_selection) + setCurrentModelType("imagery"); + setSeletedImagery(_selection); setShowChooseLocationModal(false); }} diff --git a/frontend/src/features/try-fair/api/hot-imagery.ts b/frontend/src/features/try-fair/api/hot-imagery.ts index 9701f4e3..4461436a 100644 --- a/frontend/src/features/try-fair/api/hot-imagery.ts +++ b/frontend/src/features/try-fair/api/hot-imagery.ts @@ -59,7 +59,9 @@ type StacSearchResponse = { const toImageryItem = (feature: StacImageryFeature): OAMImageryItem => { const { properties, assets } = feature; - const assetName = assets.visual ? "visual" : (Object.keys(assets)[0] ?? "visual"); + const assetName = assets.visual + ? "visual" + : (Object.keys(assets)[0] ?? "visual"); return { id: feature.id, bbox: feature.bbox, 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 index e3e11a1c..6289f4e2 100644 --- a/frontend/src/features/try-fair/components/imagery/imagery-location-modal.tsx +++ b/frontend/src/features/try-fair/components/imagery/imagery-location-modal.tsx @@ -122,13 +122,14 @@ export const ImageryLocationDialog = ({ {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. + 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 && }
-
{isOAM ? ( <> -
+
- setSelectedCell(null)} /> -
+
-
+
+
+

+ {loading + ? "Loading images…" + : `${filtered.length} image${filtered.length === 1 ? "" : "s"} in this area`} +

+ {loading && } + +
-
- - -
+
+ + +
-
- {!loading && filtered.length === 0 ? ( -

- {images.length === 0 - ? "No imagery available in this area." - : "No imagery matches the selected filters."} -

- ) : ( -
- {filtered.map((item) => ( - - onSelect(selectedItem?.id === clicked.id ? null : clicked) - } - /> - ))} -
- )} -
+
+ {!loading && filtered.length === 0 ? ( +

+ {images.length === 0 + ? "No imagery available in this area." + : "No imagery matches the selected filters."} +

+ ) : ( +
+ {filtered.map((item) => ( + + onSelect(selectedItem?.id === clicked.id ? null : clicked) + } + /> + ))} +
+ )}
+
{/*
*/} - - ); }; diff --git a/frontend/src/features/try-fair/components/imagery/location-search.tsx b/frontend/src/features/try-fair/components/imagery/location-search.tsx index dcefddd7..c9882d72 100644 --- a/frontend/src/features/try-fair/components/imagery/location-search.tsx +++ b/frontend/src/features/try-fair/components/imagery/location-search.tsx @@ -2,8 +2,8 @@ import { useEffect, useRef, useState } from "react"; import { SearchIcon, CloseIcon } from "@/components/ui/icons"; import { Spinner } from "@/components/ui/spinner"; import { - geocodeSuggestions, - GeocodeResult, + geocodeSuggestions, + GeocodeResult, } from "@/features/try-fair/api/hot-imagery"; const DEBOUNCE_MS = 350; @@ -15,135 +15,131 @@ const DEBOUNCE_MS = 350; * small screens. */ export const LocationSearch = ({ - onPick, - onClear, + onPick, + onClear, }: { - onPick: (result: GeocodeResult) => void; - /** Fired when the user clears the search (e.g. to reset the map view). */ - onClear: () => void; + onPick: (result: GeocodeResult) => void; + /** Fired when the user clears the search (e.g. to reset the map view). */ + onClear: () => void; }) => { - const [expanded, setExpanded] = useState(true); - const [query, setQuery] = useState(""); - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(false); - const [open, setOpen] = useState(false); + const [expanded, setExpanded] = useState(true); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); - const abortRef = useRef(null); - const debounceRef = useRef | undefined>( - undefined, - ); - - // Debounced suggestion fetch. - useEffect(() => { - if (!query.trim()) { - setResults([]); - setOpen(false); - return; - } - clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; - setLoading(true); - geocodeSuggestions(query.trim(), 5, controller.signal) - .then((r) => { - if (controller.signal.aborted) return; - setResults(r); - setOpen(true); - }) - .catch(() => { }) - .finally(() => { - if (!controller.signal.aborted) setLoading(false); - }); - }, DEBOUNCE_MS); - return () => clearTimeout(debounceRef.current); - }, [query]); - - const handlePick = (r: GeocodeResult) => { - setQuery(r.displayName); - setResults([]); - setOpen(false); - onPick(r); - }; + const abortRef = useRef(null); + const debounceRef = useRef | undefined>( + undefined, + ); - const handleClear = () => { - setQuery(""); - setResults([]); - setOpen(false); - onClear(); - }; - - if (!expanded) { - return ( - - ); + // Debounced suggestion fetch. + useEffect(() => { + if (!query.trim()) { + setResults([]); + setOpen(false); + return; } + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setLoading(true); + geocodeSuggestions(query.trim(), 5, controller.signal) + .then((r) => { + if (controller.signal.aborted) return; + setResults(r); + setOpen(true); + }) + .catch(() => {}) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + }, DEBOUNCE_MS); + return () => clearTimeout(debounceRef.current); + }, [query]); - return ( -
-
+ const handlePick = (r: GeocodeResult) => { + setQuery(r.displayName); + setResults([]); + setOpen(false); + onPick(r); + }; - 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" + const handleClear = () => { + setQuery(""); + setResults([]); + setOpen(false); + onClear(); + }; - /> - {loading && ( - - - - )} - {query && ( - - )} - - + ); + } - + 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) => ( -
  • - -
  • - ))} -
- )} -
- ); + {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 index 91369f2b..7caf88a3 100644 --- a/frontend/src/features/try-fair/components/imagery/oam-imagery-map.tsx +++ b/frontend/src/features/try-fair/components/imagery/oam-imagery-map.tsx @@ -75,5 +75,7 @@ export const OamImageryMap = ({ else clearImageryPreview(map); }, [map, selectedItem]); - return ; + return ( + + ); }; 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 bac2edb0..5ee953bb 100644 --- a/frontend/src/features/try-fair/components/model-picker-modal.tsx +++ b/frontend/src/features/try-fair/components/model-picker-modal.tsx @@ -76,8 +76,9 @@ export const ModelPicker: React.FC = ({
); @@ -132,49 +133,58 @@ export const ModelPickerContent = ({ models: BaseModelStacItem[]; }) => { const { isAuthenticated } = useAuth(); - const { setShowChooseLocationModal, setShowSigninModal, currentModelType, selectedImagery } = - useStartMappingStore(); -console.log(selectedImagery?.source === ImagerySource.OPEN_AERIAL_MAP ? selectedImagery.item : undefined) + const { + setShowChooseLocationModal, + setShowSigninModal, + currentModelType, + selectedImagery, + } = useStartMappingStore(); + console.log( + selectedImagery?.source === ImagerySource.OPEN_AERIAL_MAP + ? selectedImagery.item + : undefined, + ); return (
+ {selectedImagery && + selectedImagery?.source === ImagerySource.OPEN_AERIAL_MAP && ( +
+

+ Imagery: {selectedImagery.source} +

+ {/*

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

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

*/} - {/* */} - - ) -} + {/* */} + + )}

Default Locations

@@ -190,8 +200,9 @@ console.log(selectedImagery?.source === ImagerySource.OPEN_AERIAL_MAP ? selected key={model.id} type="button" onClick={() => onSelect(model)} - className={`text-left p-3 bg-frosted-blue rounded-lg transition-colors ${isSelected ? "border-primary border-2" : "" - }`} + className={`text-left p-3 bg-frosted-blue rounded-lg transition-colors ${ + isSelected ? "border-primary border-2" : "" + }`} >

@@ -199,8 +210,9 @@ console.log(selectedImagery?.source === ImagerySource.OPEN_AERIAL_MAP ? selected

{isSelected && ( diff --git a/frontend/src/features/try-fair/utils/common.tsx b/frontend/src/features/try-fair/utils/common.tsx index ecfcee93..044a4c0f 100644 --- a/frontend/src/features/try-fair/utils/common.tsx +++ b/frontend/src/features/try-fair/utils/common.tsx @@ -6,7 +6,10 @@ 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 { + DatePreset, + ResolutionPreset, +} from "@/features/try-fair/types/imagery-types"; // This is the default zoom level to start mapping. @@ -130,9 +133,6 @@ export const IMAGERY_SOURCES: { value: ImagerySource; label: string }[] = [ { value: ImagerySource.CUSTOM, label: "Custom Imagery" }, ]; - - - export const IMAGERY_DATE_OPTIONS: { label: string; value: DatePreset }[] = [ { label: "Any date", value: "" }, { label: "Past week", value: "week" }, @@ -140,7 +140,10 @@ export const IMAGERY_DATE_OPTIONS: { label: string; value: DatePreset }[] = [ { label: "Past year", value: "year" }, ]; -export const IMAGERY_RESOLUTION_PRESETS: { label: string; value: ResolutionPreset }[] = [ +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" }, @@ -167,4 +170,4 @@ export const withinResolution = ( if (preset === "05to2") return gsd >= 0.5 && gsd <= 2; if (preset === "2to10") return gsd > 2 && gsd <= 10; return gsd > 10; -}; \ No newline at end of file +}; diff --git a/frontend/src/features/try-fair/utils/start-mapping-store.ts b/frontend/src/features/try-fair/utils/start-mapping-store.ts index f622b7e6..bb9975f0 100644 --- a/frontend/src/features/try-fair/utils/start-mapping-store.ts +++ b/frontend/src/features/try-fair/utils/start-mapping-store.ts @@ -5,7 +5,7 @@ import { create } from "zustand"; type IStartMappingStore = { selectedImagery: ImagerySelection | null; setSeletedImagery: (imagery: ImagerySelection | null) => void; - + downloadType: string; setDownloadType: (imagery: string) => void; showChooseLocationModal: boolean; @@ -27,5 +27,5 @@ export const useStartMappingStore = create((set) => ({ showSigninModal: false, setShowSigninModal: (showSigninModal) => set({ showSigninModal }), currentModelType: "demo", - setCurrentModelType: (currentModelType) => set({currentModelType}) + setCurrentModelType: (currentModelType) => set({ currentModelType }), })); From 3fddc60302593f25e1457e5e09a3377992fced17 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 21 Jul 2026 14:38:30 +0100 Subject: [PATCH 22/36] feat: simplifed custom imagery flow --- frontend/src/app/routes/try-fair.tsx | 33 ++- .../shared/form/xyz-tile-server-input.tsx | 8 +- .../ui/form/help-text/help-text.tsx | 2 +- .../src/features/try-fair/api/hot-imagery.ts | 31 +++ .../imagery/custom-imagery-form.tsx | 69 ++++++- .../imagery/imagery-location-modal.tsx | 27 +-- .../components/model-picker-modal.tsx | 191 ++++++++++++------ .../try-fair/hooks/use-imagery-country.ts | 31 +++ 8 files changed, 286 insertions(+), 106 deletions(-) create mode 100644 frontend/src/features/try-fair/hooks/use-imagery-country.ts diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index 0c55ea38..36198892 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -193,6 +193,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]]; } @@ -208,18 +214,24 @@ export const TryFairPage = () => { return tileServiceUrl === FALLBACK_FAIR_IMAGERY ? FALLBACK_FAIR_IMAGERY_CENTER : DEFAULT_FAIR_IMAGERY_CENTER; - }, [tileJSONMetadata, tileServiceUrl]); + }, [tileJSONMetadata, tileServiceUrl, selectedImagery]); + 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); @@ -381,11 +393,9 @@ export const TryFairPage = () => { > { - handleSelectModel(model); - closeModelPickerDialog(); - }} + onSelect={handleSelectModel} models={models} + onClose={closeModelPickerDialog} /> @@ -393,11 +403,14 @@ export const TryFairPage = () => { setShowChooseLocationModal(false)} - onApply={(_selection) => { - // TODO: handle imagery selection + onApply={(selection) => { setCurrentModelType("imagery"); - setSeletedImagery(_selection); - + 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); }} /> 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 83ccac0a..f57d24c9 100644 --- a/frontend/src/components/shared/form/xyz-tile-server-input.tsx +++ b/frontend/src/components/shared/form/xyz-tile-server-input.tsx @@ -69,7 +69,7 @@ export const XYZTileServerInput = ({ className={ variant === "horizontal" ? "flex flex-col gap-2" - : "grid grid-cols-2 gap-4" + : "grid grid-cols-2 gap-2" } > + {isValid.message} )} +
{useAlert ? ( @@ -142,7 +144,7 @@ export const XYZTileServerInput = ({ {showButton && (
-
diff --git a/frontend/src/components/ui/form/help-text/help-text.tsx b/frontend/src/components/ui/form/help-text/help-text.tsx index db53aed8..497337ff 100644 --- a/frontend/src/components/ui/form/help-text/help-text.tsx +++ b/frontend/src/components/ui/form/help-text/help-text.tsx @@ -14,7 +14,7 @@ const HelpText: React.FC = ({ }) => { return (

0 && !isValid && "text-primary"}`} + className={` font-medium text-xs text-grey text-wrap ${isValid !== undefined && currentLength && currentLength > 0 && !isValid && "text-primary"}`} slot="help-text" > {content ?? children} diff --git a/frontend/src/features/try-fair/api/hot-imagery.ts b/frontend/src/features/try-fair/api/hot-imagery.ts index 4461436a..a4da4dfc 100644 --- a/frontend/src/features/try-fair/api/hot-imagery.ts +++ b/frontend/src/features/try-fair/api/hot-imagery.ts @@ -133,6 +133,37 @@ const toGeocodeResult = (r: NominatimResult): GeocodeResult => { }; }; +export type CountryResult = { + /** Country name, e.g. "Brazil". */ + country: string; + /** ISO 3166-1 alpha-2 code, lower-cased (e.g. "br"); "" when unknown. */ + countryCode: string; +}; + +/** + * Reverse-geocode a coordinate to its country via Nominatim. The imagery's tile + * URL carries no location, so the country is derived from the imagery's center + * (or any point inside its bounds). `zoom: 3` keeps Nominatim at country level. + */ +export const reverseGeocodeCountry = async ( + lon: number, + lat: number, + signal?: AbortSignal, +): Promise => { + const { data } = await axios.get<{ + address?: { country?: string; country_code?: string }; + }>(`${NOMINATIM_API_URL}/reverse`, { + params: { format: "json", lat, lon, zoom: 3, addressdetails: 1 }, + signal, + }); + const country = data?.address?.country; + if (!country) return null; + return { + country, + countryCode: (data.address?.country_code ?? "").toLowerCase(), + }; +}; + /** * Autocomplete suggestions for a location query (up to `limit`), each with a * bounding box for map.fitBounds. Powers the search box's suggestion dropdown. 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 index 32c672d8..0db1839f 100644 --- a/frontend/src/features/try-fair/components/imagery/custom-imagery-form.tsx +++ b/frontend/src/features/try-fair/components/imagery/custom-imagery-form.tsx @@ -1,7 +1,11 @@ -import { useState } from "react"; +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; @@ -11,8 +15,10 @@ export type AppliedCustomImagery = { /** * Custom Imagery tab of the imagery/location dialog: tile service type + tile - * server URL (via the shared XYZTileServerInput), a CORS/license note, and - * Apply. Applying previews the tiles on the shared imagery/location map below. + * 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, @@ -21,37 +27,82 @@ export const CustomImageryForm = ({ 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 ?? "", ); - const [isValid, setIsValid] = useState<{ valid: boolean; message: string }>({ - valid: false, - message: "", - }); + // 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; onApply({ tileUrl: tileServerURL, tileServiceType, bounds: null }); + 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 index 6289f4e2..e86ae330 100644 --- a/frontend/src/features/try-fair/components/imagery/imagery-location-modal.tsx +++ b/frontend/src/features/try-fair/components/imagery/imagery-location-modal.tsx @@ -23,7 +23,6 @@ import { Button } from "@/components/ui/button"; 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 { MapIcon } from "@/components/ui/icons"; import { cn } from "@/utils"; import { LocationSearch } from "./location-search"; import { ToolTip } from "@/components/ui/tooltip"; @@ -178,34 +177,12 @@ export const ImageryLocationDialog = ({
) : ( - <> +
- - {!appliedCustomImagery && ( -
- -

- No Imagery to preview -

-

- Once all field are populated correctly, the imagery will - be displayed here -

-
- )} - - {/* */} - +
)}
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 5ee953bb..0d9d67d1 100644 --- a/frontend/src/features/try-fair/components/model-picker-modal.tsx +++ b/frontend/src/features/try-fair/components/model-picker-modal.tsx @@ -1,5 +1,6 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { BaseModelStacItem } from "@/features/try-fair/api/stac"; +import { useImageryCountry } from "@/features/try-fair/hooks/use-imagery-country"; import DropDown from "@/components/ui/dropdown/dropdown"; import { useDropdownMenu } from "@/hooks/use-dropdown-menu"; import { ChevronDownIcon } from "@/components/ui/icons"; @@ -32,6 +33,38 @@ 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, @@ -43,33 +76,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

)} @@ -109,9 +140,13 @@ export const ModelPicker: React.FC = ({
{ }
@@ -123,27 +158,79 @@ 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[]; + /** Close the picker after a choice is applied. */ + onClose?: () => void; }) => { const { isAuthenticated } = useAuth(); const { setShowChooseLocationModal, setShowSigninModal, + setCurrentModelType, currentModelType, selectedImagery, } = useStartMappingStore(); - console.log( + + // The choice is staged locally and only committed on Apply, so picking a + // default location (or the imagery) doesn't change the map until Apply. + const [staged, setStaged] = useState(null); + + // Drop the staged choice whenever the committed selection changes underneath. + useEffect(() => { + setStaged(null); + }, [selectedModel, currentModelType]); + + // Derive the imagery's country by reverse-geocoding its center (the tile URL + // carries no location), shown as a badge on the imagery card. + const imageryCountry = useImageryCountry( selectedImagery?.source === ImagerySource.OPEN_AERIAL_MAP - ? selectedImagery.item - : undefined, + ? selectedImagery.bounds + : null, ); + + // Identify a selection by a single key β€” "imagery" for the selected imagery, + // or a model id for a default location. Collapsing both mutually-exclusive + // sources into one value turns every check below into a plain comparison. + const keyOf = (choice: StagedChoice): string => + choice.type === "imagery" ? IMAGERY_KEY : choice.model.id; + + const committedKey = + currentModelType === "imagery" ? IMAGERY_KEY : (selectedModel?.id ?? null); + const stagedKey = staged ? keyOf(staged) : null; + + // What the picker highlights: the staged choice if any, else the committed one. + const activeKey = stagedKey ?? committedKey; + const imageryActive = activeKey === IMAGERY_KEY; + + // Apply is enabled only when a staged choice differs from what's committed. + const hasChange = stagedKey !== null && stagedKey !== committedKey; + + const handleApply = () => { + if (!staged) return; + if (staged.type === "model") { + onSelect(staged.model); + } else { + setCurrentModelType("imagery"); + } + setStaged(null); + onClose?.(); + }; + return (
{selectedImagery && @@ -151,38 +238,29 @@ export const ModelPickerContent = ({ )}
@@ -193,13 +271,12 @@ export const ModelPickerContent = ({
{models.length > 0 ? ( models.map((model) => { - const isSelected = selectedModel?.id === model.id; - // const tasks = model.properties["mlm:tasks"] ?? []; + const isSelected = activeKey === model.id; return (

Model: {model?.properties?.["mlm:name"] ?? ""} @@ -241,7 +310,13 @@ export const ModelPickerContent = ({ )}

- 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 00000000..30494dca --- /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; +}; From 1d95c5882f91113dc3162f271737b37a58a960fa Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 21 Jul 2026 14:38:58 +0100 Subject: [PATCH 23/36] feat: simplifed custom imagery flow --- .../shared/form/xyz-tile-server-input.tsx | 65 ++++++++++--------- .../imagery/custom-imagery-form.tsx | 9 ++- .../components/model-picker-modal.tsx | 8 +-- 3 files changed, 42 insertions(+), 40 deletions(-) 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 f57d24c9..565ef8e6 100644 --- a/frontend/src/components/shared/form/xyz-tile-server-input.tsx +++ b/frontend/src/components/shared/form/xyz-tile-server-input.tsx @@ -83,35 +83,35 @@ export const XYZTileServerInput = ({ size={size as unknown as SHOELACE_SELECT_SIZES} />
- 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 ? ( @@ -144,7 +144,12 @@ export const XYZTileServerInput = ({ {showButton && (
-
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 index 0db1839f..a81b762e 100644 --- a/frontend/src/features/try-fair/components/imagery/custom-imagery-form.tsx +++ b/frontend/src/features/try-fair/components/imagery/custom-imagery-form.tsx @@ -43,10 +43,12 @@ export const CustomImageryForm = ({ // 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); + const valid = + url.length > 0 && getTileServerRegex(tileServiceType).test(url); return { valid, - message: valid || url.length === 0 ? "" : "Enter a valid tile server URL.", + message: + valid || url.length === 0 ? "" : "Enter a valid tile server URL.", }; }, [tileServerURL, tileServiceType]); @@ -102,7 +104,8 @@ const CustomImageEmptyState = () => (

No Imagery to preview

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

); 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 0d9d67d1..5ec539d7 100644 --- a/frontend/src/features/try-fair/components/model-picker-modal.tsx +++ b/frontend/src/features/try-fair/components/model-picker-modal.tsx @@ -52,13 +52,7 @@ const flagEmoji = (code: string): string => ) : "🏳️"; -const CountryBadge = ({ - country, - code, -}: { - country: string; - code: string; -}) => ( +const CountryBadge = ({ country, code }: { country: string; code: string }) => ( {flagEmoji(code)} {country} From c42e84c7d55c27d1bc2223d4e3798d114d9af0e1 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Wed, 22 Jul 2026 11:12:38 +0100 Subject: [PATCH 24/36] feat: updated search compoent --- frontend/src/app/routes/try-fair.tsx | 33 ++++++++- .../shared/form/xyz-tile-server-input.tsx | 9 +-- .../src/features/try-fair/api/hot-imagery.ts | 50 +++++++++---- .../imagery/custom-imagery-form.tsx | 8 ++- .../imagery/imagery-location-modal.tsx | 10 ++- .../components/imagery/location-search.tsx | 44 +++++++----- .../components/imagery/oam-imagery-map.tsx | 21 +++++- .../components/model-picker-modal.tsx | 72 ++++++++++--------- .../start-mapping/map-large-area-modal.tsx | 67 +++++++++++++++++ .../try-fair/components/try-fair-sidebar.tsx | 4 +- 10 files changed, 239 insertions(+), 79 deletions(-) create mode 100644 frontend/src/features/try-fair/components/start-mapping/map-large-area-modal.tsx diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index 36198892..ce66e564 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -14,7 +14,9 @@ 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, @@ -54,6 +56,8 @@ export const TryFairPage = () => { setCurrentModelType, setSeletedImagery, selectedImagery, + downloadType, + setDownloadType, } = useStartMappingStore(); const { getValue, setValue } = useLocalStorage(); const { setIsOpen: setIsSiteTourOpen, setCurrentStep, setSteps } = useTour(); @@ -216,6 +220,24 @@ export const TryFairPage = () => { : DEFAULT_FAIR_IMAGERY_CENTER; }, [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 + // "large-area"; Submit hands the chosen AOI off here β€” the offline-prediction + // request will hook in at this seam next. + const largeAreaAOIRef = useRef(null); + const handleLargeAreaSubmit = (aoi: Feature) => { + largeAreaAOIRef.current = aoi; + showSuccessToast("Area selected for mapping."); + setDownloadType(""); + }; + const mapFlownRef = useRef(false); useEffect(() => { if (!map || !selectedModel || !imageryCenter) return; @@ -419,6 +441,15 @@ export const TryFairPage = () => { closeDialog={() => setShowSigninModal(false)} /> + {/* Map Large Area (Export β†’ Map Large Area) */} + setDownloadType("")} + tileServerURL={tileserverURL} + imageryBounds={imageryBounds} + onSubmit={handleLargeAreaSubmit} + /> + {/* Signin Prompt */}
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 565ef8e6..876720a1 100644 --- a/frontend/src/components/shared/form/xyz-tile-server-input.tsx +++ b/frontend/src/components/shared/form/xyz-tile-server-input.tsx @@ -69,7 +69,7 @@ export const XYZTileServerInput = ({ className={ variant === "horizontal" ? "flex flex-col gap-2" - : "grid grid-cols-2 gap-2" + : "grid grid-cols-2 gap-3" } > setQuery(e.target.value)} - placeholder="Search place to map" - className="flex-1 px-3 py-2.5 text-sm text-dark outline-none min-w-0" - /> - - */} + ); }; diff --git a/frontend/src/features/try-fair/components/imagery/location-search.tsx b/frontend/src/features/try-fair/components/imagery/location-search.tsx index edff296d..4c707ff8 100644 --- a/frontend/src/features/try-fair/components/imagery/location-search.tsx +++ b/frontend/src/features/try-fair/components/imagery/location-search.tsx @@ -5,6 +5,7 @@ import { geocodeSuggestions, GeocodeResult, } from "@/features/try-fair/api/hot-imagery"; +import { ToolTip } from "@/components/ui/tooltip"; const DEBOUNCE_MS = 350; @@ -118,11 +119,13 @@ export const LocationSearch = ({ type="button" aria-label="Clear search" onClick={handleClear} - className="px-3 py-2.5 text-grey hover:text-dark shrink-0" + className="px-3 py-2.5 text-grey text-xs hover:text-dark shrink-0" > - + clear search )} + + +
{open && results.length > 0 && ( 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 index 27ab135f..a2319e33 100644 --- a/frontend/src/features/try-fair/components/imagery/oam-imagery-map.tsx +++ b/frontend/src/features/try-fair/components/imagery/oam-imagery-map.tsx @@ -12,6 +12,7 @@ import { SelectedCell, } from "./imagery-modal-map.layers"; import { SearchIcon } from "@/components/ui/icons"; +import { ToolTip } from "@/components/ui/tooltip"; export type { SelectedCell }; @@ -27,6 +28,7 @@ type Props = { onMapReady?: (map: MapLibreMap) => void; /** Fired when the map's search button is clicked to toggle location search. */ onToggleSearch?: () => void; + searchIconTooltipContent: string; }; /** @@ -43,6 +45,7 @@ export const OamImageryMap = ({ onCellSelect, onMapReady, onToggleSearch, + searchIconTooltipContent }: Props) => { const { map, mapContainerRef } = useImageryModalMap(); @@ -81,14 +84,18 @@ export const OamImageryMap = ({
- + + + + +
diff --git a/frontend/src/features/try-fair/components/mapping-mode.tsx b/frontend/src/features/try-fair/components/mapping-mode.tsx new file mode 100644 index 00000000..55773127 --- /dev/null +++ b/frontend/src/features/try-fair/components/mapping-mode.tsx @@ -0,0 +1,16 @@ +// import { ChevronDownIcon } from '@/components/ui/icons' +import { ModeIcon } from '@/components/ui/icons/mode-icon' + +const MappingMode = () => { + return ( +
+
+ +

Basic

+
+ {/* */} +
+ ) +} + +export default MappingMode \ No newline at end of file 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 index 3bb4dbc9..139e7dda 100644 --- 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 @@ -1,18 +1,17 @@ import { MapComponent } from "@/components/map"; import { Dialog } from "@/components/ui/dialog"; -import { MapIcon, UploadIcon } from "@/components/ui/icons"; +import { UploadIcon } from "@/components/ui/icons"; import { DrawIcon } from "@/components/ui/icons/draw-icon"; import { PictureIcon } from "@/components/ui/icons/picture-icon"; -import { SHOELACE_SIZES } from "@/enums"; import { useModalMap } from "@/features/try-fair/hooks/use-modal-map"; -import { BBOX, Feature } from "@/types"; +import { BBOX, Feature, IconProps } from "@/types"; import { useState } from "react"; // ── Tabs ──────────────────────────────────────────────────────────────────────── type AOITab = "whole" | "draw" | "upload"; -const TABS: { value: AOITab; label: string; Icon: typeof MapIcon }[] = [ +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 }, @@ -20,25 +19,23 @@ const TABS: { value: AOITab; label: string; Icon: typeof MapIcon }[] = [ const MapLargeAreaContent = ({ tileServerURL }: { tileServerURL?: string }) => { const { mapContainerRef, map } = useModalMap(); - const [activeTab, setActiveTab] = useState("draw") + const [activeTab, setActiveTab] = useState("draw"); return (
{TABS.map(({ value, label, Icon }) => ( ))}
@@ -66,14 +63,18 @@ export const MapLargeAreaModal = ({ tileServerURL?: string; imageryBounds: BBOX | null; onSubmit: (aoi: Feature) => void; -}) => ( +}) => { + return( + {/* Mount the content (and its map) only while open. */} {isOpened && } ); + +} \ No newline at end of file From cab6dc87500bae6d085c3b11a7fadabc8e5adabd Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Wed, 22 Jul 2026 19:07:13 +0100 Subject: [PATCH 28/36] feat: added mode tag in navbar --- .../src/components/ui/icons/mode-icon.tsx | 2 +- .../imagery/imagery-location-modal.tsx | 7 ++-- .../imagery/imagery-search-panel.tsx | 33 +++++++++---------- .../components/imagery/location-search.tsx | 20 +++++------ .../components/imagery/oam-imagery-map.tsx | 4 +-- .../try-fair/components/mapping-mode.tsx | 20 +++++------ .../start-mapping/map-large-area-modal.tsx | 28 ++++++++-------- 7 files changed, 52 insertions(+), 62 deletions(-) diff --git a/frontend/src/components/ui/icons/mode-icon.tsx b/frontend/src/components/ui/icons/mode-icon.tsx index fdbeed44..13e93e07 100644 --- a/frontend/src/components/ui/icons/mode-icon.tsx +++ b/frontend/src/components/ui/icons/mode-icon.tsx @@ -2,7 +2,7 @@ import { IconProps } from "@/types"; import React from "react"; export const ModeIcon: React.FC = (props) => ( - { mapRef.current = map; }} @@ -164,11 +166,8 @@ export const ImageryLocationDialog = ({ selectedItem={selectedItem} onSelect={setSelectedItem} onClose={() => setSelectedCell(null)} - handleApplyOAMItem={handleApplyOAMItem} /> - - ) : (
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 index 1e63817a..1be636bf 100644 --- a/frontend/src/features/try-fair/components/imagery/imagery-search-panel.tsx +++ b/frontend/src/features/try-fair/components/imagery/imagery-search-panel.tsx @@ -122,7 +122,7 @@ export const OAMImageryPanel = ({ selectedItem, onSelect, onClose, - handleApplyOAMItem + handleApplyOAMItem, }: { cellSelected: boolean; images: OAMImageryItem[]; @@ -206,24 +206,21 @@ export const OAMImageryPanel = ({
)}
-
- - - -
+
+ + + +
- ); }; diff --git a/frontend/src/features/try-fair/components/imagery/location-search.tsx b/frontend/src/features/try-fair/components/imagery/location-search.tsx index 4c707ff8..294d77db 100644 --- a/frontend/src/features/try-fair/components/imagery/location-search.tsx +++ b/frontend/src/features/try-fair/components/imagery/location-search.tsx @@ -124,17 +124,15 @@ export const LocationSearch = ({ clear search )} - - - - + +
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 index a2319e33..fd8d9f0f 100644 --- a/frontend/src/features/try-fair/components/imagery/oam-imagery-map.tsx +++ b/frontend/src/features/try-fair/components/imagery/oam-imagery-map.tsx @@ -45,7 +45,7 @@ export const OamImageryMap = ({ onCellSelect, onMapReady, onToggleSearch, - searchIconTooltipContent + searchIconTooltipContent, }: Props) => { const { map, mapContainerRef } = useImageryModalMap(); @@ -85,7 +85,6 @@ export const OamImageryMap = ({
- -
diff --git a/frontend/src/features/try-fair/components/mapping-mode.tsx b/frontend/src/features/try-fair/components/mapping-mode.tsx index 55773127..6160792b 100644 --- a/frontend/src/features/try-fair/components/mapping-mode.tsx +++ b/frontend/src/features/try-fair/components/mapping-mode.tsx @@ -1,16 +1,16 @@ // import { ChevronDownIcon } from '@/components/ui/icons' -import { ModeIcon } from '@/components/ui/icons/mode-icon' +import { ModeIcon } from "@/components/ui/icons/mode-icon"; const MappingMode = () => { return ( -
-
- -

Basic

-
- {/* */} +
+
+ +

Basic

+
+ {/* */}
- ) -} + ); +}; -export default MappingMode \ No newline at end of file +export default MappingMode; 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 index 139e7dda..23e1ffb2 100644 --- 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 @@ -1,6 +1,6 @@ import { MapComponent } from "@/components/map"; import { Dialog } from "@/components/ui/dialog"; -import { UploadIcon } from "@/components/ui/icons"; +import { UploadIcon } from "@/components/ui/icons"; import { DrawIcon } from "@/components/ui/icons/draw-icon"; import { PictureIcon } from "@/components/ui/icons/picture-icon"; import { useModalMap } from "@/features/try-fair/hooks/use-modal-map"; @@ -64,17 +64,15 @@ export const MapLargeAreaModal = ({ imageryBounds: BBOX | null; onSubmit: (aoi: Feature) => void; }) => { - return( - - - {/* Mount the content (and its map) only while open. */} - {isOpened && } - -); - -} \ No newline at end of file + return ( + + {/* Mount the content (and its map) only while open. */} + {isOpened && } + + ); +}; From e70c296694a6589489fa1dea387860ea8eb35bce Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Thu, 23 Jul 2026 14:32:33 +0100 Subject: [PATCH 29/36] feat: complete feature to map dropdown --- .../src/components/layouts/navbar/navbar.tsx | 47 ++++++--- .../src/components/ui/button/icon-button.tsx | 2 +- .../components/ui/icons/buildings-icon.tsx | 3 +- .../ui/icons/feature-check-icon.tsx | 20 ++++ .../components/ui/icons/solar-panel-icon.tsx | 5 +- .../src/components/ui/icons/trees-icon.tsx | 3 +- .../dropdowns/feature-to-map-dropdown.tsx | 68 +++++++++++++ .../try-fair/components/mapping-mode.tsx | 7 +- .../start-mapping/map-large-area-modal.tsx | 7 +- .../try-fair/components/try-fair-sidebar.tsx | 18 ++-- .../src/features/try-fair/utils/common.tsx | 99 ++++++++++++------- 11 files changed, 210 insertions(+), 69 deletions(-) create mode 100644 frontend/src/components/ui/icons/feature-check-icon.tsx create mode 100644 frontend/src/features/try-fair/components/dropdowns/feature-to-map-dropdown.tsx diff --git a/frontend/src/components/layouts/navbar/navbar.tsx b/frontend/src/components/layouts/navbar/navbar.tsx index 8179bd2d..7bce85bf 100644 --- a/frontend/src/components/layouts/navbar/navbar.tsx +++ b/frontend/src/components/layouts/navbar/navbar.tsx @@ -157,20 +157,16 @@ export const NavBar = () => { + ); }; @@ -322,22 +325,4 @@ const NavBarLinks: React.FC = ({ className, setOpen }) => { ); }; -const startMappingLinks = [ - { - title: "Help", - href: "/", - }, - { - title: "Share", - href: "/", - }, -]; -const StartMappingNavlinks: React.FC = () => { - return ( -
    - {startMappingLinks.map((link) => ( -
  • {link.title}
  • - ))} -
- ); -}; + diff --git a/frontend/src/components/map/setups/setup-terra-draw.ts b/frontend/src/components/map/setups/setup-terra-draw.ts index 5d39414a..e1f5f7eb 100644 --- a/frontend/src/components/map/setups/setup-terra-draw.ts +++ b/frontend/src/components/map/setups/setup-terra-draw.ts @@ -15,22 +15,34 @@ 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 +76,7 @@ export const setupTerraDraw = (map: maplibregl.Map) => { } return { valid: true }; }, + styles, }), new TerraDrawRectangleMode({ validation: (feature, { updateType }) => { @@ -74,21 +87,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/ui/copy-button.tsx b/frontend/src/components/ui/copy-button.tsx index 1f67101b..c2d06786 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 ( +
+ + {/* 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/start-mapping/map-large-area-modal.tsx b/frontend/src/features/try-fair/components/start-mapping/map-large-area-modal.tsx index 0b206c68..5ab761ba 100644 --- 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 @@ -1,19 +1,20 @@ import { MapComponent } from "@/components/map"; +import { Button } from "@/components/ui/button"; import { Dialog } from "@/components/ui/dialog"; -import { UploadIcon } from "@/components/ui/icons"; +import { DeleteIcon, UploadIcon } from "@/components/ui/icons"; import { DrawIcon } from "@/components/ui/icons/draw-icon"; import { PictureIcon } from "@/components/ui/icons/picture-icon"; -import { SHOELACE_SIZES } from "@/enums"; -import { useModalMap } from "@/features/try-fair/hooks/use-modal-map"; +import { ControlsPosition, SHOELACE_SIZES } from "@/enums"; +import { + AOITab, + useMapLargeArea, +} from "@/features/try-fair/hooks/use-map-large-area"; import { BBOX, Feature, IconProps } from "@/types"; -import { useEffect, useState } from "react"; // ── Tabs ──────────────────────────────────────────────────────────────────────── -type AOITab = "whole" | "draw" | "upload"; - const TABS: { value: AOITab; label: string; Icon: React.FC }[] = [ - { value: "whole", label: "Map Whole Imagery", Icon: PictureIcon }, + { value: "whole", label: "Whole Imagery", Icon: PictureIcon }, { value: "draw", label: "Draw Specific Area", Icon: DrawIcon }, { value: "upload", label: "Upload Area of Interest", Icon: UploadIcon }, ]; @@ -21,51 +22,110 @@ const TABS: { value: AOITab; label: string; Icon: React.FC }[] = [ const MapLargeAreaContent = ({ tileServerURL, imageryBounds, + onSubmit, + closeDialog, }: { tileServerURL?: string; imageryBounds?: BBOX | null; + onSubmit: (aoi: Feature) => void; + closeDialog: () => void; }) => { - const { mapContainerRef, map } = useModalMap(); - const [activeTab, setActiveTab] = useState("draw"); - - 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]); + 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 */} +
+
); @@ -76,7 +136,7 @@ export const MapLargeAreaModal = ({ closeDialog, tileServerURL, imageryBounds, - // onSubmit, + onSubmit, }: { isOpened: boolean; closeDialog: () => void; @@ -91,11 +151,12 @@ export const MapLargeAreaModal = ({ closeDialog={closeDialog} size={SHOELACE_SIZES.LARGE} > - {/* Mount the content (and its map) only while open. */} {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 00000000..955a16d8 --- /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) => ( +
  • + + + +
  • + ))} +
+ ); +}; \ No newline at end of file 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 00000000..e9c94728 --- /dev/null +++ b/frontend/src/features/try-fair/hooks/use-map-large-area.ts @@ -0,0 +1,408 @@ +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/utils/start-mapping-store.ts b/frontend/src/features/try-fair/utils/start-mapping-store.ts index bb9975f0..b16697ed 100644 --- a/frontend/src/features/try-fair/utils/start-mapping-store.ts +++ b/frontend/src/features/try-fair/utils/start-mapping-store.ts @@ -12,6 +12,8 @@ type IStartMappingStore = { setShowChooseLocationModal: (show: boolean) => void; showSigninModal: boolean; setShowSigninModal: (show: boolean) => void; + showShareModal: boolean; + setShowShareModal: (show: boolean) => void; currentModelType: "demo" | "imagery"; setCurrentModelType: (type: "demo" | "imagery") => void; }; @@ -26,6 +28,8 @@ export const useStartMappingStore = create((set) => ({ 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/hooks/use-map-instance.ts b/frontend/src/hooks/use-map-instance.ts index ddfad8c6..1098278a 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 From ac904202caac07d98f85becbb88ad7be4d6099f0 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Thu, 23 Jul 2026 22:22:54 +0100 Subject: [PATCH 35/36] feat: added share project modal --- frontend/src/app/routes/try-fair.tsx | 5 +- .../src/components/layouts/navbar/navbar.tsx | 2 - .../components/map/setups/setup-terra-draw.ts | 3 +- frontend/src/components/ui/copy-button.tsx | 1 - frontend/src/components/ui/icons/index.ts | 1 - .../components/modals/share-project-modal.tsx | 1 - .../start-mapping/map-large-area-modal.tsx | 4 +- .../components/try-fair-nav-links.tsx | 58 +++++++++---------- .../try-fair/hooks/use-map-large-area.ts | 5 +- 9 files changed, 39 insertions(+), 41 deletions(-) diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index d7897cb6..d8bc2eb9 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -231,14 +231,13 @@ export const TryFairPage = () => { }, [selectedImagery, tileJSONMetadata]); // Map Large Area (Export β†’ Map Large Area). Opens when downloadType is set to -const navigate = useNavigate() + 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) - + navigate(APPLICATION_ROUTES.PROFILE_OFFLINE_PREDICTIONS); }; const mapFlownRef = useRef(false); diff --git a/frontend/src/components/layouts/navbar/navbar.tsx b/frontend/src/components/layouts/navbar/navbar.tsx index b4fa4e1e..c94b4de8 100644 --- a/frontend/src/components/layouts/navbar/navbar.tsx +++ b/frontend/src/components/layouts/navbar/navbar.tsx @@ -324,5 +324,3 @@ const NavBarLinks: React.FC = ({ className, setOpen }) => { ); }; - - diff --git a/frontend/src/components/map/setups/setup-terra-draw.ts b/frontend/src/components/map/setups/setup-terra-draw.ts index e1f5f7eb..2efa8150 100644 --- a/frontend/src/components/map/setups/setup-terra-draw.ts +++ b/frontend/src/components/map/setups/setup-terra-draw.ts @@ -20,7 +20,8 @@ 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, + outlineColor: + TRAINING_AREAS_AOI_OUTLINE_COLOR as TerraDrawExtend.HexColorStyling, outlineWidth: TRAINING_AREAS_AOI_OUTLINE_WIDTH, }; diff --git a/frontend/src/components/ui/copy-button.tsx b/frontend/src/components/ui/copy-button.tsx index c2d06786..981059f5 100644 --- a/frontend/src/components/ui/copy-button.tsx +++ b/frontend/src/components/ui/copy-button.tsx @@ -52,4 +52,3 @@ export const CopyButton = ({ ); }; - diff --git a/frontend/src/components/ui/icons/index.ts b/frontend/src/components/ui/icons/index.ts index 33c808ca..b07a0169 100644 --- a/frontend/src/components/ui/icons/index.ts +++ b/frontend/src/components/ui/icons/index.ts @@ -76,4 +76,3 @@ export { TryFairPredictionToggleIcon } from "./try-fair-prediction-toggle-icon"; export { SparklesIcon } from "./sparkles-icon"; export { EyeClosedIcon } from "./eye-closed-icon"; export { LinkIcon } from "./link-icon"; - 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 index 4b9e5d69..553c32d7 100644 --- a/frontend/src/features/try-fair/components/modals/share-project-modal.tsx +++ b/frontend/src/features/try-fair/components/modals/share-project-modal.tsx @@ -158,4 +158,3 @@ export const ShareProjectModal: React.FC = ({ ); }; - 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 index 5ab761ba..37d1fde0 100644 --- 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 @@ -101,8 +101,8 @@ const MapLargeAreaContent = ({ {activeTab === "upload" ? uploadedFileName || "Mapping AOI.geojson" : activeTab === "draw" - ? "Drawn AOI" - : "Whole Imagery AOI"} + ? "Drawn AOI" + : "Whole Imagery AOI"} - - - ))} - - ); -}; \ No newline at end of file + return ( +
    + {startMappingLinks.map((link) => ( +
  • + +
  • + ))} +
+ ); +}; 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 index e9c94728..aa8a6da9 100644 --- a/frontend/src/features/try-fair/hooks/use-map-large-area.ts +++ b/frontend/src/features/try-fair/hooks/use-map-large-area.ts @@ -88,7 +88,10 @@ export const useMapLargeArea = ({ const updateMapLayer = () => { const geojsonData: FeatureCollection = selectedAOI - ? { type: "FeatureCollection", features: [selectedAOI as unknown as Feature] } + ? { + type: "FeatureCollection", + features: [selectedAOI as unknown as Feature], + } : { type: "FeatureCollection", features: [] }; const source = map.getSource(SOURCE_ID) as GeoJSONSource | undefined; From e256e9cfecafecce65ff893cb8f081a0e2b5a4f9 Mon Sep 17 00:00:00 2001 From: Emmanuel Jolaiya Date: Fri, 24 Jul 2026 07:08:55 +0200 Subject: [PATCH 36/36] Remove 'Open in JOSM' option from dropdown Removed JOSM option from the dropdown menu. --- .../components/start-mapping/export-map-results.tsx | 6 ------ 1 file changed, 6 deletions(-) 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 index cd8693db..131df058 100644 --- 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 @@ -1,4 +1,3 @@ -import { JOSMLogo } from "@/assets/svgs"; import { DropDown } from "@/components/ui/dropdown"; import { DropdownMenuItem } from "@/components/ui/dropdown/dropdown"; import { @@ -15,11 +14,6 @@ export const EXPORT_MAP_MENU_ITEMS: Omit[] = [ value: "download", Icon: CloudDownloadIcon, }, - { - label: "Open in JOSM", - value: "josm", - imgSrc: JOSMLogo, - }, { label: "Map Large Area", value: "large-area",