Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ef21223
chore: added empty state texts
superwaih Jul 3, 2026
7daa267
chore: added empty state texts
superwaih Jul 3, 2026
591218c
updated models text
superwaih Jul 3, 2026
d62647a
chore: added review updates
superwaih Jul 14, 2026
4eaa473
updated header banner
superwaih Jul 14, 2026
8f4971d
updated header banner
superwaih Jul 14, 2026
9d0762d
feat: added snap to grid
superwaih Jul 14, 2026
b2ba8a1
updated range slider
superwaih Jul 14, 2026
50c778a
updated range slider
superwaih Jul 14, 2026
0d9eda2
Rename FAIR_STAC_CATALOG_BASE_URL to VITE_FAIR_STAC_CATALOG_BASE_URL
jeafreezy Jul 15, 2026
0911bec
updated default params
superwaih Jul 15, 2026
ea76107
chore: updated params name
superwaih Jul 15, 2026
8ee6394
Merge branch 'feat/try-fair' of https://github.com/Spatialnode/fAIr i…
superwaih Jul 15, 2026
2ac76e0
chore: remove high and small label
superwaih Jul 15, 2026
8ce9a09
chore: updated texts in parameters
superwaih Jul 16, 2026
d1ba8af
feat: added new icons
superwaih Jul 17, 2026
96cb339
feat: added new icons
superwaih Jul 17, 2026
76f3764
feat: added try fair imagery selector
superwaih Jul 18, 2026
6eb25d5
feat: added signin prompt modal
superwaih Jul 20, 2026
bcef51e
feat: added signin prompt modal
superwaih Jul 20, 2026
1dbe1de
feat: added imagery selector
superwaih Jul 21, 2026
e080c30
feat: added imagery selector
superwaih Jul 21, 2026
3fddc60
feat: simplifed custom imagery flow
superwaih Jul 21, 2026
1d95c58
feat: simplifed custom imagery flow
superwaih Jul 21, 2026
c42e84c
feat: updated search compoent
superwaih Jul 22, 2026
26cdff9
chore: updated search types
superwaih Jul 22, 2026
41513b4
feat: updated icons for map large area
superwaih Jul 22, 2026
2e0da8f
feat: added mode tag in navbar
superwaih Jul 22, 2026
cab6dc8
feat: added mode tag in navbar
superwaih Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand Down
138 changes: 121 additions & 17 deletions frontend/src/app/routes/try-fair.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ import { ModelPickerContent } from "@/features/try-fair/components/model-picker-
import { getSelectedModel } from "@/features/try-fair/utils/models";
import { useMapInstance } from "@/hooks/use-map-instance";
import { useTileservice } from "@/hooks/use-tileservice";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getTileServerRegex, getTileServerTypeFromURL } from "@/utils";
import { useTryFairParams } from "@/features/try-fair/hooks/use-try-fair-params";
import {
useBaseModels,
useLocalModels,
} from "@/features/try-fair/hooks/use-base-models";
import { BBOX } from "@/types";
import { BBOX, Feature } from "@/types";
import { showSuccessToast } from "@/utils";
import { MapLargeAreaModal } from "@/features/try-fair/components/start-mapping/map-large-area-modal";
import { useFairPredict } from "@/features/try-fair/hooks/use-fair-predict";
import {
getTryFairGuidedTourSteps,
Expand All @@ -38,10 +40,25 @@ import {
} from "@/features/try-fair/utils/common";
import { Dialog } from "@/components/ui/dialog";
import { useDialog } from "@/hooks/use-dialog";
import { ImageryLocationDialog } from "@/features/try-fair/components/imagery/imagery-location-modal";
import { useStartMappingStore } from "@/features/try-fair/utils/start-mapping-store";
import { SignInPromptDialog } from "@/features/try-fair/components/modals/sign-in-prompt";

export const TryFairPage = () => {
const { map, mapContainerRef } = useMapInstance(false, false);
const { isSmallViewport } = useScreenSize();
const {
showChooseLocationModal,
setShowChooseLocationModal,
showSigninModal,
setShowSigninModal,
currentModelType,
setCurrentModelType,
setSeletedImagery,
selectedImagery,
downloadType,
setDownloadType,
} = useStartMappingStore();
const { getValue, setValue } = useLocalStorage();
const { setIsOpen: setIsSiteTourOpen, setCurrentStep, setSteps } = useTour();

Expand All @@ -54,6 +71,8 @@ export const TryFairPage = () => {
setOutputType,
setResolution,
setConfidence,
isParametersDefault,
resetParameters,
} = useTryFairParams();

const { models: allModels, loading: modelsLoading } = useBaseModels();
Expand Down Expand Up @@ -89,7 +108,24 @@ export const TryFairPage = () => {
const [latestBBox, setLatestBBox] = useState<BBOX | null>(null);

const [latestGridZoom, setLatestGridZoom] = useState<number | null>(null);
const [isDirty, setIsDirty] = useState<boolean>(true);

// Snapshot of the current prediction inputs vs what was last submitted,
const lastPredictedInputsRef = useRef<string | null>(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,
Expand All @@ -107,11 +143,15 @@ export const TryFairPage = () => {
);

const tileServiceUrl = useMemo(() => {
const modelImagery = selectedModel?.properties["fair:source_imagery"];
const modelImagery =
currentModelType === "demo"
? selectedModel?.properties["fair:source_imagery"]
: selectedImagery?.tileUrl;
if (!modelImagery) return FALLBACK_FAIR_IMAGERY;
const regex = getTileServerRegex(getTileServerTypeFromURL(modelImagery));
return regex.test(modelImagery) ? modelImagery : FALLBACK_FAIR_IMAGERY;
}, [selectedModel]);
}, [selectedModel, currentModelType, selectedImagery]);

const {
tileserverURL,
setTileserverURL,
Expand Down Expand Up @@ -157,6 +197,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]];
}
Expand All @@ -172,18 +218,42 @@ export const TryFairPage = () => {
return tileServiceUrl === FALLBACK_FAIR_IMAGERY
? FALLBACK_FAIR_IMAGERY_CENTER
: DEFAULT_FAIR_IMAGERY_CENTER;
}, [tileJSONMetadata, tileServiceUrl]);
}, [tileJSONMetadata, tileServiceUrl, selectedImagery]);

// The current imagery's extent, used as the "Whole Imagery" AOI in the
// Map Large Area modal.
const imageryBounds = useMemo<BBOX | null>(() => {
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<Feature | null>(null);
const handleLargeAreaSubmit = (aoi: Feature) => {
largeAreaAOIRef.current = aoi;
showSuccessToast("Area selected for mapping.");
setDownloadType("");
};

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);
Expand All @@ -203,6 +273,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
Expand All @@ -213,27 +284,25 @@ 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],
);

const handleBBoxChange = useCallback((bbox: BBOX, tileZoom: number) => {
setLatestBBox(bbox);
setLatestGridZoom(tileZoom);
setIsDirty(true);
}, []);

const guidedTourSteps = useMemo<StepType[]>(
Expand Down Expand Up @@ -274,7 +343,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]) =>
Expand All @@ -301,6 +371,7 @@ export const TryFairPage = () => {
tileserverURL,
latestGridZoom,
resolution,
predictionInputsSnapshot,
]);

const isMapButtonDisabled = !isDirty || !latestBBox || !demoConfig;
Expand Down Expand Up @@ -338,20 +409,49 @@ export const TryFairPage = () => {

{/* Model picker dialog – rendered at page level so it's not trapped inside MobileDrawer */}
<Dialog
label="Choose a Model"
label="Where do you want to map?"
isOpened={isModelPickerDialogOpened}
closeDialog={closeModelPickerDialog}
>
<ModelPickerContent
selectedModel={selectedModel}
onSelect={(model) => {
handleSelectModel(model);
closeModelPickerDialog();
}}
onSelect={handleSelectModel}
models={models}
onClose={closeModelPickerDialog}
/>
</Dialog>

{/* Imagery/location dialog – rendered at page level */}
<ImageryLocationDialog
isOpened={showChooseLocationModal}
closeDialog={() => setShowChooseLocationModal(false)}
onApply={(selection) => {
setCurrentModelType("imagery");
setSeletedImagery(selection);
// Invalidate the last prediction so the Map button re-enables and
// stale predictions clear when the imagery changes. The map re-centers
// on the new imagery via the imageryCenter/flyTo effect.
lastPredictedInputsRef.current = null;
clearPredictions();
setShowChooseLocationModal(false);
}}
/>
<SignInPromptDialog
isOpened={showSigninModal}
closeDialog={() => setShowSigninModal(false)}
/>

{/* Map Large Area (Export → Map Large Area) */}
<MapLargeAreaModal
isOpened={downloadType === "large-area"}
closeDialog={() => setDownloadType("")}
tileServerURL={tileserverURL}
imageryBounds={imageryBounds}
onSubmit={handleLargeAreaSubmit}
/>

{/* Signin Prompt */}

<div className="flex h-screen md:h-[92vh] flex-col fullscreen">
<div className="flex-grow relative">
<TryFairMap
Expand Down Expand Up @@ -387,6 +487,8 @@ export const TryFairPage = () => {
inferenceParams={inferenceParams}
paramValues={paramValues}
onParamChange={handleParamChange}
onResetParameters={resetParameters}
isParametersDefault={isParametersDefault}
onMap={handleMap}
isPredicting={isPredicting}
isMapButtonDisabled={isMapButtonDisabled}
Expand Down Expand Up @@ -415,6 +517,8 @@ export const TryFairPage = () => {
inferenceParams={inferenceParams}
paramValues={paramValues}
onParamChange={handleParamChange}
onResetParameters={resetParameters}
isParametersDefault={isParametersDefault}
onMap={handleMap}
isPredicting={isPredicting}
isMapButtonDisabled={isMapButtonDisabled}
Expand Down
7 changes: 3 additions & 4 deletions frontend/src/components/landing/header/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -22,18 +21,18 @@ export const Header = () => {
nativeAnchor={false}
>
<Button rounded capitalize={false}>
{SHARED_CONTENT.homepage.ctaPrimaryButton}
{SHARED_CONTENT.homepage.ctaSecondaryButton}
</Button>
</Link>
<Link
{/* <Link
href={APPLICATION_ROUTES.HEADER_START_MAPPING}
title={SHARED_CONTENT.homepage.ctaPrimaryButton}
nativeAnchor={false}
>
<Button rounded variant={ButtonVariant.SECONDARY}>
{SHARED_CONTENT.homepage.ctaSecondaryButton}
</Button>
</Link>
</Link> */}
</div>
</div>
<div className={styles.jumbotronImage}>
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/components/layouts/navbar/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
import "@hotosm/tool-menu";
import { Divider } from "@/components/ui/divider";
import { ToolTip } from "@/components/ui/tooltip";
import ExportMapResults from "@/features/try-fair/components/start-mapping/export-map-results";
import MappingMode from "@/features/try-fair/components/mapping-mode";

if (AUTH_PROVIDER === "hanko") {
import("@hotosm/hanko-auth");
Expand Down Expand Up @@ -161,6 +163,13 @@ export const NavBar = () => {
<NavBarLinks className={styles.webNavLinks} />
</div>
)}

{isTryFairPage && (
<div className="hidden sm:flex">
<MappingMode />
</div>
)}

<div className="hidden sm:flex items-center gap-x-3">
{AUTH_PROVIDER === "hanko" && !IS_DEV ? (
<>
Expand All @@ -170,7 +179,9 @@ export const NavBar = () => {
</>
) : isAuthenticated ? (
<>
{isAuthenticated && <UserNotifications />}
{isAuthenticated && !isTryFairPage && <UserNotifications />}
{isAuthenticated && isTryFairPage && <ExportMapResults />}

<UserProfile />
</>
) : (
Expand Down
Loading