From 04d9d4a806e960a827f1d593d9abd242fadcf4b3 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Mon, 2 Mar 2026 17:18:55 +0100 Subject: [PATCH 01/62] feat: completed base models --- .../adr-choose-url-state-library/adr1.md | 40 ++ frontend/.husky/pre-commit | 11 +- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 37 ++ frontend/src/app/router.tsx | 33 +- .../routes/base-model/base-model-detail.tsx | 386 ++++++++++++++++++ .../src/app/routes/base-model/base-models.tsx | 139 +++++++ frontend/src/app/routes/landing.tsx | 2 + .../assets/images/base_model_cta_image.png | Bin 0 -> 60747 bytes frontend/src/assets/images/index.ts | 2 + .../base-model-cta/base-model-cta.module.css | 147 +++++++ .../landing/base-model-cta/base-model-cta.tsx | 39 ++ .../src/components/ui/icons/download-icon.tsx | 16 + frontend/src/constants/routes.ts | 5 + .../constants/ui-contents/shared-content.ts | 113 +++++ .../components/base-model-card.tsx | 48 +++ .../components/base-models-filters.tsx | 129 ++++++ .../components/contribute-model-dialog.tsx | 182 +++++++++ frontend/src/styles/index.css | 4 + frontend/src/types/ui-contents.ts | 42 ++ frontend/tailwind.config.js | 6 +- 21 files changed, 1377 insertions(+), 5 deletions(-) create mode 100644 docs/decisions/frontend/architecture/adr-choose-url-state-library/adr1.md create mode 100644 frontend/src/app/routes/base-model/base-model-detail.tsx create mode 100644 frontend/src/app/routes/base-model/base-models.tsx create mode 100644 frontend/src/assets/images/base_model_cta_image.png create mode 100644 frontend/src/components/landing/base-model-cta/base-model-cta.module.css create mode 100644 frontend/src/components/landing/base-model-cta/base-model-cta.tsx create mode 100644 frontend/src/features/base-models/components/base-model-card.tsx create mode 100644 frontend/src/features/base-models/components/base-models-filters.tsx create mode 100644 frontend/src/features/base-models/components/contribute-model-dialog.tsx diff --git a/docs/decisions/frontend/architecture/adr-choose-url-state-library/adr1.md b/docs/decisions/frontend/architecture/adr-choose-url-state-library/adr1.md new file mode 100644 index 000000000..d6a7ab4b3 --- /dev/null +++ b/docs/decisions/frontend/architecture/adr-choose-url-state-library/adr1.md @@ -0,0 +1,40 @@ +# Architecture Decision Record 1: Use nuqs for URL-based UI State Management + +Date: 02/03/2026 + +# Context + +The frontend currently has multiple pages with search and filter controls that are reflected in URL query parameters. Historically, some pages managed this with ad-hoc utilities and manual synchronization between component state and `useSearchParams`, which increased complexity and inconsistency. + +We have validated `nuqs` in the start mapping flow and found it to be a performant and ergonomic approach for query-string state handling. As more pages require URL-based state, we need a consistent, typed pattern across the frontend. + +## Decision Drivers + +- Consistent URL-state behavior across routes with search and filters. +- Better type safety and parsing for query params than manual string handling. +- Simpler implementation and maintenance compared to custom synchronization utilities. +- Good performance for frequent UI state updates tied to query parameters. +- Better developer experience and readability for future feature work. + +## Considered Options + +- Continue using `react-router-dom` `useSearchParams` with custom helper utilities. +- Use [`nuqs`](https://nuqs.dev/) as the standard query-state library. +- Keep filter/search state only in component/global state and avoid URL synchronization. + +# Decision + +We will standardize on `nuqs` for managing URL-based state in frontend routes that need query-parameter-backed UI state (for example: search text, filters, sorting, map/list toggles, pagination, and similar controls). + +`react-router-dom` `useSearchParams` may still be used for simple one-off cases, but all new or refactored complex query-state flows should use `nuqs` as the default pattern. + +# Status + +Accepted. + +# Consequences + +- Query-state logic becomes more consistent and easier to reason about across pages. +- We reduce repeated boilerplate for parsing/serializing query parameters. +- Existing pages that use custom URL-state utilities may need incremental migration to align with this decision. +- Team members should follow the `nuqs` pattern used in the start mapping implementation as the reference approach. diff --git a/frontend/.husky/pre-commit b/frontend/.husky/pre-commit index 0d6108f45..8623572aa 100755 --- a/frontend/.husky/pre-commit +++ b/frontend/.husky/pre-commit @@ -1,3 +1,8 @@ -cd frontend -pnpm format -pnpm build +#!/bin/sh +echo "===== Navigating to project directory =====" +cd frontend || exit 1 + +echo "===== Running precommit script =====" +pnpm precommit + +echo "===== Pre-commit hook finished =====" diff --git a/frontend/package.json b/frontend/package.json index f01b4cb4c..c79fae168 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,7 @@ "framer-motion": "^12.19.1", "geojson": "^0.5.0", "maplibre-gl": "^5.3.1", + "nuqs": "^2.8.8", "pmtiles": "^4.3.0", "react": "19.1.0", "react-confetti-explosion": "^3.0.3", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 22a863e89..941752d8e 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: maplibre-gl: specifier: ^5.3.1 version: 5.3.1 + nuqs: + specifier: ^2.8.8 + version: 2.8.8(react-router-dom@6.26.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-router@6.26.2(react@19.1.0))(react@19.1.0) pmtiles: specifier: ^4.3.0 version: 4.3.0 @@ -933,6 +936,9 @@ packages: resolution: {integrity: sha512-fB9+bPHLg5zVwPbBKEqY3ghyttkJq9RuUzFMTZKweKrNKKDMUACtI8DlMYUqNwpdZMJhf7a0xeak6vFVBSxcbQ==} engines: {node: '>=14.17.0'} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@tailwindcss/typography@0.5.15': resolution: {integrity: sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==} peerDependencies: @@ -2578,6 +2584,27 @@ packages: resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} engines: {node: '>=0.10.0'} + nuqs@2.8.8: + resolution: {integrity: sha512-LF5sw9nWpHyPWzMMu9oho3r9C5DvkpmBIg4LQN78sexIzGaeRx8DWr0uy3YiFx5i2QGZN1Qqcb+OAtEVRa2bnA==} + peerDependencies: + '@remix-run/react': '>=2' + '@tanstack/react-router': ^1 + next: '>=14.2.0' + react: '>=18.2.0 || ^19.0.0-0' + react-router: ^5 || ^6 || ^7 + react-router-dom: ^5 || ^6 || ^7 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@tanstack/react-router': + optional: true + next: + optional: true + react-router: + optional: true + react-router-dom: + optional: true + nwsapi@2.2.16: resolution: {integrity: sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==} @@ -4161,6 +4188,8 @@ snapshots: transitivePeerDependencies: - '@types/react' + '@standard-schema/spec@1.0.0': {} + '@tailwindcss/typography@0.5.15(tailwindcss@3.4.13)': dependencies: lodash.castarray: 4.4.0 @@ -6298,6 +6327,14 @@ snapshots: normalize-range@0.1.2: {} + nuqs@2.8.8(react-router-dom@6.26.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-router@6.26.2(react@19.1.0))(react@19.1.0): + dependencies: + '@standard-schema/spec': 1.0.0 + react: 19.1.0 + optionalDependencies: + react-router: 6.26.2(react@19.1.0) + react-router-dom: 6.26.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + nwsapi@2.2.16: {} object-assign@4.1.1: {} diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 3f544b69c..ccc883439 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -12,6 +12,7 @@ import { createBrowserRouter, } from "react-router-dom"; import { ModelsProvider } from "@/app/providers/models-provider"; +import { NuqsAdapter } from "nuqs/adapters/react-router/v6"; const router = createBrowserRouter([ { @@ -53,6 +54,32 @@ const router = createBrowserRouter([ // }, /** * Models details, list and feedbacks route starts. + */ + + /** + * Base Models routes. + */ + { + path: APPLICATION_ROUTES.BASE_MODELS_HOME, + lazy: async () => { + const { BaseModelsPage } = await import("@/app/routes/base-model/base-models"); + return { + Component: () => , + }; + }, + }, + { + path: APPLICATION_ROUTES.BASE_MODEL_DETAILS_PAGE, + lazy: async () => { + const { BaseModelDetailPage } = await import("@/app/routes/base-model/base-model-detail"); + return { + Component: () => , + }; + }, + }, + + /** + * Base Models routes ends. */ { path: APPLICATION_ROUTES.MODEL_DETAILS, @@ -441,5 +468,9 @@ const router = createBrowserRouter([ ]); export const AppRouter = () => { - return ; + return ( + + + + ); }; diff --git a/frontend/src/app/routes/base-model/base-model-detail.tsx b/frontend/src/app/routes/base-model/base-model-detail.tsx new file mode 100644 index 000000000..9ec2d4207 --- /dev/null +++ b/frontend/src/app/routes/base-model/base-model-detail.tsx @@ -0,0 +1,386 @@ +import { Head } from "@/components/seo"; +import { BackButton, ButtonWithIcon } from "@/components/ui/button"; +import { ChevronDownIcon, InfoIcon } from "@/components/ui/icons"; +import { DownloadIconNew } from "@/components/ui/icons/download-icon"; +import { ToolTip } from "@/components/ui/tooltip"; +import { APPLICATION_ROUTES } from "@/constants"; +import { ButtonVariant } from "@/enums"; +import { + BASE_MODELS_DETAIL_DATA, + TBaseModelDetail, + TBaseModelVariant, +} from "@/features/base-models/data/base-model-data"; +import AccuracyDisplay from "@/features/models/components/accuracy-display"; +import { useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +type TInfoRowConfig = { + label: string; + value: string; + tooltip?: string; +}; + +type TMetadataItemProps = { + label: string; + value: React.ReactNode; + tooltip?: string; +}; + +/** + * Collapsible section component for the right sidebar. + */ +const CollapsibleSection = ({ + title, + children, + defaultOpen = true, +}: { + title: string; + children: React.ReactNode; + defaultOpen?: boolean; +}) => { + const [isOpen, setIsOpen] = useState(defaultOpen); + + return ( +
+ + {isOpen &&
{children}
} +
+ ); +}; + +const MetadataItem = ({ label, value, tooltip }: TMetadataItemProps) => ( +
+ {label}: + {value} + {tooltip && ( + + + + )} +
+); + +/** + * Info row for displaying a label/value pair with an optional info tooltip. + */ +const InfoRow = ({ + label, + value, + tooltip, +}: { + label: string; + value: string; + tooltip?: string; +}) => ( +
+
+ {label} + {tooltip && ( + + + + )} +
+

{value}

+
+); + +/** + * Variant display component. + */ +const VariantCard = ({ variant }: { variant: TBaseModelVariant }) => ( +
+
+ Name: + {variant.name} +
+
+ Classes: + + {variant.classes} + +
+
+ Notes: + {variant.notes} +
+
+); + +export const BaseModelDetailPage = () => { + const { id } = useParams(); + const navigate = useNavigate(); + + const model: TBaseModelDetail | undefined = useMemo(() => { + return BASE_MODELS_DETAIL_DATA.find((m) => String(m.id) === id); + }, [id]); + + const architectureRows: TInfoRowConfig[] = model + ? [ + { label: "Base Model", value: model.architecture.baseModel }, + { label: "Head", value: model.architecture.head }, + { + label: "Input", + value: model.architecture.input, + tooltip: "Input format used by the model", + }, + { label: "Tile Size px", value: model.architecture.tileSizePx }, + { + label: "Processing", + value: model.architecture.processing, + tooltip: "Pre-processing steps applied", + }, + { + label: "Resize", + value: model.architecture.resize, + tooltip: "How images are resized before inference", + }, + { + label: "Scaling", + value: model.architecture.scaling, + tooltip: "Pixel value normalization method", + }, + { + label: "Output", + value: model.architecture.output, + tooltip: "Model output format", + }, + { + label: "Description", + value: model.architecture.outputDescription, + tooltip: "Description of the model output", + }, + ] + : []; + + const dataInfoRows: TInfoRowConfig[] = model + ? [ + { + label: "Sensor", + value: model.dataInfo.sensor, + tooltip: "Type of sensor used to capture imagery", + }, + { + label: "CRS", + value: model.dataInfo.crs, + tooltip: "Coordinate Reference System", + }, + { + label: "Spatial Extent", + value: model.dataInfo.spatialExtent, + tooltip: "Geographic coverage of training data", + }, + { + label: "Temporal Extent", + value: model.dataInfo.temporalExtent, + tooltip: "Time period of training data", + }, + ] + : []; + + + + // If model not found, redirect to 404 + if (!model) { + return ( +
+

+ Model Not Found +

+

+ The base model you are looking for does not exist. +

+ +
+ ); + } + + return ( + <> + + + +
+ {/* Title + Start Mapping */} +
+
+

+ {model.fullTitle} +

+

Model ID: {model.dataId}

+
+
+ + navigate(`${APPLICATION_ROUTES.START_MAPPING_BASE}${model.id}`) + } + variant={ButtonVariant.PRIMARY} + label="Start Mapping" + /> +
+
+ + {/* Metadata Row */} +
+
+ + + +
+
+ + + +
+
+ +
+ Accuracy: + +
+ +
+
+ + {/* Download Metadata Link */} +
+ +
+ + {/* Main Content: Two Column Layout */} +
+ {/* Left Column - Overview */} +
+ {/* Overview */} +
+

+ Overview +

+ {model.overview.split("\n\n").map((paragraph, i) => ( +

+ {paragraph} +

+ ))} +
+ + {/* Use Cases */} +
+

+ Use Cases +

+
+
+

+ Suitable for: +

+
    + {model.useCases.suitable.map((item, i) => ( +
  • {item}
  • + ))} +
+
+
+

+ Not suitable for: +

+
    + {model.useCases.notSuitable.map((item, i) => ( +
  • {item}
  • + ))} +
+
+
+
+ + {/* Performance */} +
+

+ Performance +

+

{model.performance}

+
+ + {/* Limitations */} +
+

+ Limitations +

+
    + {model.limitations.map((item, i) => ( +
  1. {item}
  2. + ))} +
+
+
+ + {/* Right Column - Architecture Info */} +
+ +
+ {architectureRows.map((row) => ( + + ))} +
+
+ + +
+ {model.architecture.variants.map((variant, i) => ( + + ))} +
+
+ + +
+ {dataInfoRows.map((row) => ( + + ))} +
+
+
+
+
+ + ); +}; diff --git a/frontend/src/app/routes/base-model/base-models.tsx b/frontend/src/app/routes/base-model/base-models.tsx new file mode 100644 index 000000000..f38668d9f --- /dev/null +++ b/frontend/src/app/routes/base-model/base-models.tsx @@ -0,0 +1,139 @@ +import { Head } from "@/components/seo"; +import { ButtonWithIcon } from "@/components/ui/button"; +import { AddIcon } from "@/components/ui/icons"; +import { SHARED_CONTENT } from "@/constants"; +import { ButtonVariant } from "@/enums"; +import { + BASE_MODELS_DATA, + TASK_CATEGORIES, + DATE_SORT_OPTIONS, +} from "@/features/base-models/data/base-model-data"; +import { useDialog } from "@/hooks/use-dialog"; +import { useMemo } from "react"; +import { useQueryState, parseAsString } from "nuqs"; +import BaseModelCard from "@/features/base-models/components/base-model-card"; +import ContributeModelDialog from "@/features/base-models/components/contribute-model-dialog"; +import BaseModelsFilters from "@/features/base-models/components/base-models-filters"; + +export const BaseModelsPage = () => { + const { isOpened, openDialog, closeDialog } = useDialog(); + // nuqs-powered search params state + const [search, setSearch] = useQueryState("q", parseAsString.withDefault("")); + const [category, setCategory] = useQueryState( + "category", + parseAsString.withDefault("all"), + ); + const [dateSort, setDateSort] = useQueryState( + "date", + parseAsString.withDefault("newest"), + ); + const [mapView, setMapView] = useQueryState( + "map", + parseAsString.withDefault("false"), + ); + + const isMapViewActive = mapView === "true"; + + // Filter and sort models + const filteredModels = useMemo(() => { + let models = [...BASE_MODELS_DATA]; + + // Search filter + if (search) { + const searchLower = search.toLowerCase(); + models = models.filter( + (model) => + model.name.toLowerCase().includes(searchLower) || + model.description.toLowerCase().includes(searchLower) || + model.author.toLowerCase().includes(searchLower), + ); + } + + // Category filter + if (category && category !== "all") { + models = models.filter((model) => model.task === category); + } + + // Date sorting + if (dateSort === "oldest") { + models.reverse(); + } + + return models; + }, [search, category, dateSort]); + + // Category dropdown items + const categoryMenuItems = TASK_CATEGORIES.map((cat) => ({ + value: cat.label, + apiValue: cat.value, + })); + + // Date dropdown items + const dateMenuItems = DATE_SORT_OPTIONS.map((opt) => ({ + value: opt.label, + apiValue: opt.value, + })); + + const selectedCategoryLabel = + TASK_CATEGORIES.find((c) => c.value === category)?.label || "Category"; + + const selectedDateLabel = + DATE_SORT_OPTIONS.find((d) => d.value === dateSort)?.label || "Date"; + return ( + <> + + + +
+ {/* Header */} +
+
+

+ {SHARED_CONTENT.baseModelsPage.pageHeadingTitle} +

+
+ +
+
+

+ {SHARED_CONTENT.baseModelsPage.pageHeadingDescription} +

+
+ + + + {filteredModels.length === 0 ? ( +
+

No models found

+

+ Try adjusting your search or filter criteria. +

+
+ ) : ( +
+ {filteredModels.map((model) => ( + + ))} +
+ )} +
+ + ); +}; diff --git a/frontend/src/app/routes/landing.tsx b/frontend/src/app/routes/landing.tsx index 1e1de3a67..b592620d9 100644 --- a/frontend/src/app/routes/landing.tsx +++ b/frontend/src/app/routes/landing.tsx @@ -10,6 +10,7 @@ import { CoreFeatures, WhatIsFAIR, } from "@/components/landing"; +import { BaseModelCTA } from "@/components/landing/base-model-cta/base-model-cta"; export const LandingPage = () => { return ( @@ -19,6 +20,7 @@ export const LandingPage = () => { +
diff --git a/frontend/src/assets/images/base_model_cta_image.png b/frontend/src/assets/images/base_model_cta_image.png new file mode 100644 index 0000000000000000000000000000000000000000..29502cd5ac539b98bf2a5eb3325088bc3de175f8 GIT binary patch literal 60747 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%0Ax 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 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/images/index.ts b/frontend/src/assets/images/index.ts index 81dfe28cc..9e8c423bb 100644 --- a/frontend/src/assets/images/index.ts +++ b/frontend/src/assets/images/index.ts @@ -15,5 +15,7 @@ export { default as BeginnerCourseImage } from "@/assets/images/beginner_course. export { default as IntermediateCourseImage } from "@/assets/images/intermediate_course.png"; export { default as AdvancedCourseImage } from "@/assets/images/advanced_course.png"; export { default as UpdateCoverImage } from "@/assets/images/cover.png"; +export { default as BaseModelCTAImage } from "@/assets/images/base_model_cta_image.png"; + export { default as fAIrSwipeIllustration } from "@/assets/images/fairswipe_illustration.png"; diff --git a/frontend/src/components/landing/base-model-cta/base-model-cta.module.css b/frontend/src/components/landing/base-model-cta/base-model-cta.module.css new file mode 100644 index 000000000..644bf5819 --- /dev/null +++ b/frontend/src/components/landing/base-model-cta/base-model-cta.module.css @@ -0,0 +1,147 @@ +.container { + padding: 0; + height: auto; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + width: 100%; + margin-bottom: 100px; + background-image: url("../../../assets/svgs/contour_background.svg"); + background-size: cover; + background-position: center; + background-repeat: no-repeat; + background-color: var(--hot-fair-color-primary); + overflow: hidden; + position: relative; + min-height: 439px; +} + +.container .cta { + padding: 40px var(--sl-spacing-large); + width: 100%; + justify-content: center; + height: auto; + display: flex; + flex-direction: column; + z-index: 1; +} + +.ctaContent { + padding: var(--sl-spacing-large) 0; + display: flex; + flex-direction: column; + gap: var(--sl-spacing-medium); + justify-content: start; + height: 100%; + width: 100%; +} + +.ctaButtonContainer { + max-width: 213px; + width: 70%; + padding-top: var(--sl-spacing-medium); +} + +.container .cta h1 { + font-size: var(--hot-fair-font-size-title-2); + font-weight: var(--hot-fair-font-weight-semibold); + color: white; + line-height: 1.2; +} + +.container .cta p { + font-size: var(--hot-fair-font-size-body-text-2base); + font-weight: var(--hot-fair-font-weight-regular); + color: var(--hot-fair-color-secondary); + line-height: 1.5; + max-width: 420px; +} + +.imageBlock { + width: 300px; + position: absolute; + right: -180px; + bottom: 0; + display: flex; + align-items: flex-end; + justify-content: center; + overflow: visible; + pointer-events: none; +} + +.image { + width: 100%; + height: auto; + object-fit: contain; +} + +/* md: */ +@media (min-width: 768px) { + .container { + padding: 0 var(--hot-fair-spacing-extra-large); + min-height: 320px; + align-items: center; + justify-content: space-between; + flex-direction: row; + gap: var(--hot-fair-spacing-extra-large); + } + + .ctaContent { + justify-content: center; + } + + .container .cta { + padding: 40px 0; + color: var(--hot-fair-color-dark); + display: flex; + flex-direction: column; + justify-content: center; + width: 50%; + flex-shrink: 0; + } + + .container .cta h1 { + font-size: var(--hot-fair-font-size-title-3); + } + + .imageBlock { + position: relative; + width: 50%; + right: 0; + bottom: auto; + display: flex; + align-items: center; + justify-content: center; + min-width: unset; + } + + .image { + height: auto; + object-fit: contain; + max-width: 460px; + } +} + +/* lg: */ +@media (min-width: 1024px) { + .container { + min-height: 340px; + } + + .ctaContent { + width: 90%; + } + + .container .cta h1 { + font-size: var(--hot-fair-font-size-large-title); + } + + .container .cta p { + font-size: var(--hot-fair-font-size-body-text-2); + } + + .image { + max-width: 500px; + } +} diff --git a/frontend/src/components/landing/base-model-cta/base-model-cta.tsx b/frontend/src/components/landing/base-model-cta/base-model-cta.tsx new file mode 100644 index 000000000..b03eff616 --- /dev/null +++ b/frontend/src/components/landing/base-model-cta/base-model-cta.tsx @@ -0,0 +1,39 @@ +import styles from "./base-model-cta.module.css"; +import { Button } from "@/components/ui/button/"; +import { BaseModelCTAImage } from "@/assets/images"; +import { Image } from "@/components/ui/image"; +import { Link } from "@/components/ui/link"; +import { SHARED_CONTENT } from "@/constants"; +import { ButtonVariant } from "@/enums"; + +export const BaseModelCTA = () => { + return ( +
+
+
+

{SHARED_CONTENT.homepage.baseModelCTA.title}

+

{SHARED_CONTENT.homepage.baseModelCTA.description}

+
+
+ + + +
+
+
+ {SHARED_CONTENT.homepage.baseModelCTA.title} +
+
+ ); +}; diff --git a/frontend/src/components/ui/icons/download-icon.tsx b/frontend/src/components/ui/icons/download-icon.tsx index a4cc9407a..72bb565f9 100644 --- a/frontend/src/components/ui/icons/download-icon.tsx +++ b/frontend/src/components/ui/icons/download-icon.tsx @@ -14,3 +14,19 @@ export const DownloadIcon: React.FC = (props) => ( /> ); + +export const DownloadIconNew = (props: IconProps) => ( + + + +) diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index c29be2e37..4560a55f7 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -27,6 +27,11 @@ export const APPLICATION_ROUTES = { MODEL_DETAILS: `${MODELS_BASE}/:id`, MODEL_FEEDBACKS: `${MODELS_BASE}/:id/feedbacks`, + // base-model start + BASE_MODELS_HOME: "/base-models", + BASE_MODEL_DETAILS_PAGE: "/base-models/:id", + // base-model end + // Model routes start CREATE_NEW_MODEL: `${MODELS_ROUTES.CREATE_MODEL_BASE}/${MODELS_ROUTES.DETAILS}`, diff --git a/frontend/src/constants/ui-contents/shared-content.ts b/frontend/src/constants/ui-contents/shared-content.ts index 3be718a5a..450589869 100644 --- a/frontend/src/constants/ui-contents/shared-content.ts +++ b/frontend/src/constants/ui-contents/shared-content.ts @@ -138,6 +138,12 @@ export const SHARED_CONTENT: TSharedContent = { paragraph: "fAIr is a collaborative project. We welcome all types of experience to join our community on HOTOSM Slack. There is always a room for AI/ML for earth observation expertise, community engagement enthusiastic, academic researcher or student looking for an academic challenge around social impact.", }, + baseModelCTA: { + title: "Contribute your Base Model", + description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.", + ctaButton: "Contribute", + ctaLink: "/base-models", + }, }, pageNotFound: { messages: { @@ -152,6 +158,113 @@ export const SHARED_CONTENT: TSharedContent = { pageNotFound: "go to homepage", }, }, + baseModelsPage: { + pageHeadingTitle: "Base Models", + pageHeadingDescription: " Each model is trained using one of the training datasets. Published models can be used to find mappable features in imagery that is similar to the training areas that dataset comes from.", + pageHeadingButtonText: "Contribute model", + contributeModelDialog: { + label: "Model Contribution Journey", + intro: + "Model contribution into fAIr is handled in GITHUB /fAIr-models repository. Here are high level explanation for the contribution four steps and detailed documentation is available when you go to GITHUB", + statusBadgeClasses: { + pending: "bg-status-pending-bg text-status-text", + changes: "bg-status-changes-bg text-status-text", + approved: "bg-status-approved-bg text-status-text", + }, + github: { + title: "Fair Model github", + href: "https://github.com/hotosm/fAIr-models", + buttonLabel: "GO TO GITHUB", + }, + steps: [ + { + title: "Complete Prerequisites", + description: + "Before opening a Pull Request, verify your model meets the technical and legal standards.", + sections: [ + { + title: "Define Licenses", + description: + "AI models require three distinct licenses. You must select one for each category:", + listType: "unordered", + items: [ + "Code License: (e.g., Apache 2.0, MIT, or GPLv3)", + "Weights License: (e.g., Apache 2.0, CC BY 4.0, or Custom)", + "Data License: (e.g., CC BY, CC BY-NC, or Custom Terms)", + ], + note: + "Note: This will be automatically validated if your selections are HOT-compliant to prevent future rejection.", + }, + { + title: "Verify Model Endpoints", + description: + "Ensure your model code includes the four mandatory API endpoints:", + listType: "ordered", + items: [ + "Training: For model fine-tuning.", + "Inference: For generating predictions.", + "Preprocessing: For imagery preparation.", + "Postprocessing: For cleaning and formatting results.", + ], + }, + { + title: "Define Input/Output Shape", + description: + "Clearly describe the data formats your model handles.", + listType: "unordered", + items: [ + "Input Example: Image RGB (tiles) + GeoJSON (labels)", + "Output Example: GeoJSON (detections) or Mask raster (segmentation)", + ], + }, + { + title: "Select Task Category", + description: "Choose one of the currently supported tasks:", + listType: "unordered", + items: [ + "Semantic Segmentation", + "Instance Segmentation", + "Object Detection (Selected for this session)", + ], + }, + ], + }, + { + title: "Review Guidelines", + description: + "To align with our community standards, you must read and acknowledge the contribution rules.", + }, + { + title: "Submit and Track PR", + description: + "After reviewing the guidelines and finished the prerequisites, you can now open a PR.", + }, + { + title: "Approval & Deployment", + description: + "Your contribution enters the final review stage by the fAIr maintainers.", + statuses: [ + { + variant: "pending", + label: "🟡 Pending", + description: "Under review by maintainers or CI is running.", + }, + { + variant: "changes", + label: "🔴 Needs Changes", + description: + "Feedback has been provided; updates are required.", + }, + { + variant: "approved", + label: "🟢 Approved", + description: "PR is merged! Your model is now a fAIr base model.", + }, + ], + }, + ], + }, + }, protectedPage: { ctaButton: "login", messageParagraph: "To access this page you have to login.", diff --git a/frontend/src/features/base-models/components/base-model-card.tsx b/frontend/src/features/base-models/components/base-model-card.tsx new file mode 100644 index 000000000..dfeda6d99 --- /dev/null +++ b/frontend/src/features/base-models/components/base-model-card.tsx @@ -0,0 +1,48 @@ +import { Link } from "@/components/ui/link"; +import { APPLICATION_ROUTES } from "@/constants"; +import { roundNumber } from "@/utils/number-utils"; +import { TBaseModel } from "../data/base-model-data"; + +type BaseModelCardProps = { + model: TBaseModel; +}; + +const BaseModelCard: React.FC = ({ model }) => { + return ( + + {/* Model Name */} +

{model.name}

+ + {/* Description */} +

+ {model.description} +

+ + {/* Accuracy */} +
+

Accuracy:

+

+ {roundNumber(model.accuracy)} +

+
+ + {/* Author & Date */} +
+

+ {model.author} +

+

+ Last Modified: {model.lastModified} +

+
+ + ); +}; + +export default BaseModelCard; diff --git a/frontend/src/features/base-models/components/base-models-filters.tsx b/frontend/src/features/base-models/components/base-models-filters.tsx new file mode 100644 index 000000000..3286a5813 --- /dev/null +++ b/frontend/src/features/base-models/components/base-models-filters.tsx @@ -0,0 +1,129 @@ +import { DropDown } from "@/components/ui/dropdown"; +import { Switch } from "@/components/ui/form"; +import { FilterIcon, ListIcon, SearchIcon } from "@/components/ui/icons"; +import { DATE_SORT_OPTIONS, TASK_CATEGORIES } from "@/features/base-models/data/base-model-data"; + +type MenuItem = { + value: string; + apiValue: string; +}; + +type BaseModelsFiltersProps = { + search: string; + setSearch: (value: string | null) => void; + categoryMenuItems: MenuItem[]; + dateMenuItems: MenuItem[]; + selectedCategoryLabel: string; + selectedDateLabel: string; + setCategory: (value: string | null) => void; + setDateSort: (value: string | null) => void; + isMapViewActive: boolean; + setMapView: (value: string | null) => void; + filteredModelsCount: number; +}; + +const BaseModelsFilters: React.FC = ({ + search, + setSearch, + categoryMenuItems, + dateMenuItems, + selectedCategoryLabel, + selectedDateLabel, + setCategory, + setDateSort, + isMapViewActive, + setMapView, + filteredModelsCount, +}) => { + return ( +
+
+
+
+
+ + setSearch(e.target.value || null)} + placeholder="Search" + className="w-full p-2 outline-none border-none text-body-2base" + /> +
+ +
+ { + const selected = TASK_CATEGORIES.find((c) => c.label === value); + if (selected) { + setCategory(selected.value === "all" ? null : selected.value); + } + }} + defaultSelectedItem={selectedCategoryLabel} + triggerComponent={ +

{selectedCategoryLabel}

+ } + /> +
+ +
+ { + const selected = DATE_SORT_OPTIONS.find((d) => d.label === value); + if (selected) { + setDateSort(selected.value === "newest" ? null : selected.value); + } + }} + defaultSelectedItem={selectedDateLabel} + triggerComponent={ +

{selectedDateLabel}

+ } + /> +
+ + + +
+ +
+
+

Map View

+ { + setMapView(isMapViewActive ? null : "true"); + }} + /> +
+ +
+
+
+ +
+

{filteredModelsCount} Models

+
+

Map View

+ { + setMapView(isMapViewActive ? null : "true"); + }} + /> +
+
+
+ ); +}; + +export default BaseModelsFilters; \ No newline at end of file diff --git a/frontend/src/features/base-models/components/contribute-model-dialog.tsx b/frontend/src/features/base-models/components/contribute-model-dialog.tsx new file mode 100644 index 000000000..f1442e2a9 --- /dev/null +++ b/frontend/src/features/base-models/components/contribute-model-dialog.tsx @@ -0,0 +1,182 @@ +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { ChevronDownIcon } from "@/components/ui/icons"; +import { Link } from "@/components/ui/link"; +import { SHARED_CONTENT } from "@/constants/ui-contents/shared-content"; +import { useState } from "react"; + +type ContributeModelDialogProps = { + isOpened: boolean; + closeDialog: () => void; +}; + +type StepProps = { + stepNumber: number; + title: string; + children: React.ReactNode; + isExpanded: boolean; + onToggle: () => void; +}; + +const Step: React.FC = ({ + stepNumber, + title, + children, + isExpanded, + onToggle, +}) => { + return ( +
+ + {isExpanded &&
{children}
} +
+ ); +}; + +const StatusBadge = ({ + className, + label, +}: { + className: string; + label: string; +}) => { + return ( + + {label} + + ); +}; + +const ContributeModelDialog: React.FC = ({ + isOpened, + closeDialog, +}) => { + const contributeModelDialogContent = + SHARED_CONTENT.baseModelsPage.contributeModelDialog; + const [expandedStep, setExpandedStep] = useState(1); + + const handleToggle = (step: number) => { + setExpandedStep((prev) => (prev === step ? -1 : step)); + }; + + return ( + +
+

+ {contributeModelDialogContent.intro} +

+ + {contributeModelDialogContent.steps.map((step, index) => ( + handleToggle(index + 1)} + > +
+ {step.description && ( +

{step.description}

+ )} + + {step.sections && ( +
+ {step.sections.map((section) => ( +
+

+ {section.title} +

+ + {section.description && ( +

+ {section.description} +

+ )} + + {section.items && section.items.length > 0 && ( + <> + {section.listType === "ordered" ? ( +
    + {section.items.map((item) => ( +
  1. {item}
  2. + ))} +
+ ) : ( +
    + {section.items.map((item) => ( +
  • {item}
  • + ))} +
+ )} + + )} + + {section.note && ( +

+ {section.note} +

+ )} +
+ ))} +
+ )} + + {step.statuses && ( +
+ {step.statuses.map((status) => ( +
+ + + {status.description} + +
+ ))} +
+ )} +
+
+ ))} + + {/* Go to GitHub Button */} +
+ + + +
+
+
+ ); +}; + +export default ContributeModelDialog; diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index 4cc2edfc3..446b7a4a4 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -27,6 +27,10 @@ body { --hot-fair-color-green-primary: #198155; --hot-fair-color-frosted-blue: #f7f9fb; --hot-fair-color-ink: #202325; + --hot-fair-color-status-pending-bg: #fff9da; + --hot-fair-color-status-changes-bg: #ffe3da; + --hot-fair-color-status-approved-bg: #dfffda; + --hot-fair-color-status-text: #6c7072; /* Font sizes in rem */ --hot-fair-font-size-extra-large: 4.25rem; diff --git a/frontend/src/types/ui-contents.ts b/frontend/src/types/ui-contents.ts index 767763912..169cccdf2 100644 --- a/frontend/src/types/ui-contents.ts +++ b/frontend/src/types/ui-contents.ts @@ -302,6 +302,7 @@ export type TModelsContent = { }; }; }; + }; // Models related pages content types ends. @@ -432,7 +433,48 @@ export type TSharedContent = { ctaLink: string; paragraph: string; }; + baseModelCTA: { + title: string; + description: string; + ctaButton: string; + ctaLink: string; + }; }; + baseModelsPage:{ + pageHeadingTitle: string; + pageHeadingDescription: string; + pageHeadingButtonText: string; + contributeModelDialog: { + label: string; + intro: string; + statusBadgeClasses: { + pending: string; + changes: string; + approved: string; + }; + github: { + title: string; + href: string; + buttonLabel: string; + }; + steps: { + title: string; + description?: string; + sections?: { + title: string; + description?: string; + listType?: "unordered" | "ordered"; + items?: string[]; + note?: string; + }[]; + statuses?: { + variant: "pending" | "changes" | "approved"; + label: string; + description: string; + }[]; + }[]; + }; + } pageNotFound: { messages: { constant: string; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 8d30b66ac..bbeddb494 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -15,7 +15,11 @@ export default { "hover-accent": "var( --hot-fair-color-hover-accent)", "green-secondary": "var(--hot-fair-color-green-secondary)", "green-primary": "var(--hot-fair-color-green-primary)", - "frosted-blue": "var(--hot-fair-color-frosted-blue)" + "frosted-blue": "var(--hot-fair-color-frosted-blue)", + "status-pending-bg": "var(--hot-fair-color-status-pending-bg)", + "status-changes-bg": "var(--hot-fair-color-status-changes-bg)", + "status-approved-bg": "var(--hot-fair-color-status-approved-bg)", + "status-text": "var(--hot-fair-color-status-text)", }, fontFamily: { archivo: "var(--sl-font-sans)", From 48edd44d812e0e236360c6afd81c56466225eee6 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Mon, 2 Mar 2026 17:19:51 +0100 Subject: [PATCH 02/62] feat: completed base models page --- frontend/.husky/pre-commit | 11 ++---- frontend/src/app/router.tsx | 16 +++++---- .../routes/base-model/base-model-detail.tsx | 12 +++---- .../src/app/routes/base-model/base-models.tsx | 2 +- frontend/src/assets/images/index.ts | 1 - .../landing/base-model-cta/base-model-cta.tsx | 1 - .../src/components/ui/icons/download-icon.tsx | 4 +-- .../constants/ui-contents/shared-content.ts | 12 +++---- .../components/base-models-filters.tsx | 35 ++++++++++++++----- frontend/src/types/ui-contents.ts | 5 ++- 10 files changed, 55 insertions(+), 44 deletions(-) diff --git a/frontend/.husky/pre-commit b/frontend/.husky/pre-commit index 8623572aa..5ecd813c5 100755 --- a/frontend/.husky/pre-commit +++ b/frontend/.husky/pre-commit @@ -1,8 +1,3 @@ -#!/bin/sh -echo "===== Navigating to project directory =====" -cd frontend || exit 1 - -echo "===== Running precommit script =====" -pnpm precommit - -echo "===== Pre-commit hook finished =====" +cd frontend +pnpm format +pnpm build \ No newline at end of file diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index ccc883439..c747e8ce4 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -56,29 +56,33 @@ const router = createBrowserRouter([ * Models details, list and feedbacks route starts. */ - /** + /** * Base Models routes. */ - { + { path: APPLICATION_ROUTES.BASE_MODELS_HOME, lazy: async () => { - const { BaseModelsPage } = await import("@/app/routes/base-model/base-models"); + const { BaseModelsPage } = await import( + "@/app/routes/base-model/base-models" + ); return { Component: () => , }; }, }, - { + { path: APPLICATION_ROUTES.BASE_MODEL_DETAILS_PAGE, lazy: async () => { - const { BaseModelDetailPage } = await import("@/app/routes/base-model/base-model-detail"); + const { BaseModelDetailPage } = await import( + "@/app/routes/base-model/base-model-detail" + ); return { Component: () => , }; }, }, - /** + /** * Base Models routes ends. */ { diff --git a/frontend/src/app/routes/base-model/base-model-detail.tsx b/frontend/src/app/routes/base-model/base-model-detail.tsx index 9ec2d4207..7fd8ff566 100644 --- a/frontend/src/app/routes/base-model/base-model-detail.tsx +++ b/frontend/src/app/routes/base-model/base-model-detail.tsx @@ -186,8 +186,6 @@ export const BaseModelDetailPage = () => { ] : []; - - // If model not found, redirect to 404 if (!model) { return ( @@ -246,7 +244,10 @@ export const BaseModelDetailPage = () => { label="Model Weights License" value={model.modelWeightsLicense} /> - +
{ {/* Download Metadata Link */}
- diff --git a/frontend/src/app/routes/base-model/base-models.tsx b/frontend/src/app/routes/base-model/base-models.tsx index f38668d9f..4c20a3160 100644 --- a/frontend/src/app/routes/base-model/base-models.tsx +++ b/frontend/src/app/routes/base-model/base-models.tsx @@ -119,7 +119,7 @@ export const BaseModelsPage = () => { filteredModelsCount={filteredModels.length} /> - {filteredModels.length === 0 ? ( + {filteredModels.length === 0 ? (

No models found

diff --git a/frontend/src/assets/images/index.ts b/frontend/src/assets/images/index.ts index 9e8c423bb..163f020fc 100644 --- a/frontend/src/assets/images/index.ts +++ b/frontend/src/assets/images/index.ts @@ -17,5 +17,4 @@ export { default as AdvancedCourseImage } from "@/assets/images/advanced_course. export { default as UpdateCoverImage } from "@/assets/images/cover.png"; export { default as BaseModelCTAImage } from "@/assets/images/base_model_cta_image.png"; - export { default as fAIrSwipeIllustration } from "@/assets/images/fairswipe_illustration.png"; diff --git a/frontend/src/components/landing/base-model-cta/base-model-cta.tsx b/frontend/src/components/landing/base-model-cta/base-model-cta.tsx index b03eff616..d6138871f 100644 --- a/frontend/src/components/landing/base-model-cta/base-model-cta.tsx +++ b/frontend/src/components/landing/base-model-cta/base-model-cta.tsx @@ -19,7 +19,6 @@ export const BaseModelCTA = () => { href={SHARED_CONTENT.homepage.baseModelCTA.ctaLink} title={SHARED_CONTENT.homepage.baseModelCTA.ctaButton} nativeAnchor - >

@@ -73,14 +82,20 @@ const BaseModelsFilters: React.FC = ({ menuItems={dateMenuItems} withCheckbox handleMenuSelection={(value: string) => { - const selected = DATE_SORT_OPTIONS.find((d) => d.label === value); + const selected = DATE_SORT_OPTIONS.find( + (d) => d.label === value, + ); if (selected) { - setDateSort(selected.value === "newest" ? null : selected.value); + setDateSort( + selected.value === "newest" ? null : selected.value, + ); } }} defaultSelectedItem={selectedDateLabel} triggerComponent={ -

{selectedDateLabel}

+

+ {selectedDateLabel} +

} />
@@ -111,7 +126,9 @@ const BaseModelsFilters: React.FC = ({
-

{filteredModelsCount} Models

+

+ {filteredModelsCount} Models +

Map View

= ({ ); }; -export default BaseModelsFilters; \ No newline at end of file +export default BaseModelsFilters; diff --git a/frontend/src/types/ui-contents.ts b/frontend/src/types/ui-contents.ts index 169cccdf2..845ed596d 100644 --- a/frontend/src/types/ui-contents.ts +++ b/frontend/src/types/ui-contents.ts @@ -302,7 +302,6 @@ export type TModelsContent = { }; }; }; - }; // Models related pages content types ends. @@ -440,7 +439,7 @@ export type TSharedContent = { ctaLink: string; }; }; - baseModelsPage:{ + baseModelsPage: { pageHeadingTitle: string; pageHeadingDescription: string; pageHeadingButtonText: string; @@ -474,7 +473,7 @@ export type TSharedContent = { }[]; }[]; }; - } + }; pageNotFound: { messages: { constant: string; From eb0d5c2dce1f4cc5158d470967403a4e80662ec0 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 3 Mar 2026 23:12:42 +0100 Subject: [PATCH 03/62] chore: implement all review corrections --- frontend/src/app/router.tsx | 6 ++-- .../base-model-detail.tsx | 36 +++++++++---------- .../base-models-list.tsx} | 36 +++++++++---------- frontend/src/app/routes/not-found.tsx | 36 ++++++++++--------- frontend/src/constants/routes.ts | 2 +- .../constants/ui-contents/shared-content.ts | 7 +--- .../components/base-model-card.tsx | 3 +- .../components/contribute-model-dialog.tsx | 16 +++++---- frontend/src/styles/index.css | 6 ++-- frontend/src/types/ui-contents.ts | 6 +--- frontend/tailwind.config.js | 6 ++-- 11 files changed, 73 insertions(+), 87 deletions(-) rename frontend/src/app/routes/{base-model => base-models}/base-model-detail.tsx (94%) rename frontend/src/app/routes/{base-model/base-models.tsx => base-models/base-models-list.tsx} (85%) diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index c747e8ce4..17471ab8f 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -63,7 +63,7 @@ const router = createBrowserRouter([ path: APPLICATION_ROUTES.BASE_MODELS_HOME, lazy: async () => { const { BaseModelsPage } = await import( - "@/app/routes/base-model/base-models" + "@/app/routes/base-models/base-models-list" ); return { Component: () => , @@ -71,10 +71,10 @@ const router = createBrowserRouter([ }, }, { - path: APPLICATION_ROUTES.BASE_MODEL_DETAILS_PAGE, + path: APPLICATION_ROUTES.BASE_MODEL_DETAILS, lazy: async () => { const { BaseModelDetailPage } = await import( - "@/app/routes/base-model/base-model-detail" + "@/app/routes/base-models/base-model-detail" ); return { Component: () => , diff --git a/frontend/src/app/routes/base-model/base-model-detail.tsx b/frontend/src/app/routes/base-models/base-model-detail.tsx similarity index 94% rename from frontend/src/app/routes/base-model/base-model-detail.tsx rename to frontend/src/app/routes/base-models/base-model-detail.tsx index 7fd8ff566..2c710c124 100644 --- a/frontend/src/app/routes/base-model/base-model-detail.tsx +++ b/frontend/src/app/routes/base-models/base-model-detail.tsx @@ -11,7 +11,7 @@ import { TBaseModelVariant, } from "@/features/base-models/data/base-model-data"; import AccuracyDisplay from "@/features/models/components/accuracy-display"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; type TInfoRowConfig = { @@ -38,7 +38,7 @@ const CollapsibleSection = ({ children: React.ReactNode; defaultOpen?: boolean; }) => { - const [isOpen, setIsOpen] = useState(defaultOpen); + const [isOpen, setIsOpen] = useState(defaultOpen); return (
@@ -123,6 +123,20 @@ export const BaseModelDetailPage = () => { return BASE_MODELS_DETAIL_DATA.find((m) => String(m.id) === id); }, [id]); + useEffect(() => { + if (!model) { + navigate(APPLICATION_ROUTES.NOTFOUND, { + replace: true, + state: { + from: APPLICATION_ROUTES.BASE_MODELS_HOME, + error: "base model not found", + buttonLabel: "Back to Base Models", + redirectPath: APPLICATION_ROUTES.BASE_MODELS_HOME, + }, + }); + } + }, [model, navigate]); + const architectureRows: TInfoRowConfig[] = model ? [ { label: "Base Model", value: model.architecture.baseModel }, @@ -186,24 +200,8 @@ export const BaseModelDetailPage = () => { ] : []; - // If model not found, redirect to 404 if (!model) { - return ( -
-

- Model Not Found -

-

- The base model you are looking for does not exist. -

- -
- ); + return null; } return ( diff --git a/frontend/src/app/routes/base-model/base-models.tsx b/frontend/src/app/routes/base-models/base-models-list.tsx similarity index 85% rename from frontend/src/app/routes/base-model/base-models.tsx rename to frontend/src/app/routes/base-models/base-models-list.tsx index 4c20a3160..f83858f12 100644 --- a/frontend/src/app/routes/base-model/base-models.tsx +++ b/frontend/src/app/routes/base-models/base-models-list.tsx @@ -10,7 +10,7 @@ import { } from "@/features/base-models/data/base-model-data"; import { useDialog } from "@/hooks/use-dialog"; import { useMemo } from "react"; -import { useQueryState, parseAsString } from "nuqs"; +import { parseAsString, useQueryStates } from "nuqs"; import BaseModelCard from "@/features/base-models/components/base-model-card"; import ContributeModelDialog from "@/features/base-models/components/contribute-model-dialog"; import BaseModelsFilters from "@/features/base-models/components/base-models-filters"; @@ -18,19 +18,15 @@ import BaseModelsFilters from "@/features/base-models/components/base-models-fil export const BaseModelsPage = () => { const { isOpened, openDialog, closeDialog } = useDialog(); // nuqs-powered search params state - const [search, setSearch] = useQueryState("q", parseAsString.withDefault("")); - const [category, setCategory] = useQueryState( - "category", - parseAsString.withDefault("all"), - ); - const [dateSort, setDateSort] = useQueryState( - "date", - parseAsString.withDefault("newest"), - ); - const [mapView, setMapView] = useQueryState( - "map", - parseAsString.withDefault("false"), - ); + const [ + { q: search, category, date: dateSort, map: mapView }, + setQueryStates, + ] = useQueryStates({ + q: parseAsString.withDefault(""), + category: parseAsString.withDefault("all"), + date: parseAsString.withDefault("newest"), + map: parseAsString.withDefault("false"), + }); const isMapViewActive = mapView === "true"; @@ -50,7 +46,7 @@ export const BaseModelsPage = () => { } // Category filter - if (category && category !== "all") { + if (category !== "all") { models = models.filter((model) => model.task === category); } @@ -105,17 +101,17 @@ export const BaseModelsPage = () => {

- setQueryStates({ q: value })} categoryMenuItems={categoryMenuItems} dateMenuItems={dateMenuItems} selectedCategoryLabel={selectedCategoryLabel} selectedDateLabel={selectedDateLabel} - setCategory={setCategory} - setDateSort={setDateSort} + setCategory={(value) => setQueryStates({ category: value })} + setDateSort={(value) => setQueryStates({ date: value })} isMapViewActive={isMapViewActive} - setMapView={setMapView} + setMapView={(value) => setQueryStates({ map: value })} filteredModelsCount={filteredModels.length} /> diff --git a/frontend/src/app/routes/not-found.tsx b/frontend/src/app/routes/not-found.tsx index 63a1d8af6..2483c57d7 100644 --- a/frontend/src/app/routes/not-found.tsx +++ b/frontend/src/app/routes/not-found.tsx @@ -5,15 +5,28 @@ import { useLocation, useNavigate } from "react-router-dom"; export const PageNotFound = () => { const location = useLocation(); + const fromPath = location.state?.from ?? ""; + const buttonLabelFromState = location.state?.buttonLabel; + const redirectPathFromState = location.state?.redirectPath; - const modelNotFound = location.state?.from.includes( - APPLICATION_ROUTES.MODELS, - ); + const modelNotFound = fromPath.includes(APPLICATION_ROUTES.MODELS); - const trainingDatasetNotFound = location.state?.from.includes( + const trainingDatasetNotFound = fromPath.includes( APPLICATION_ROUTES.DATASETS, ); + const fallbackRedirectPath = modelNotFound + ? APPLICATION_ROUTES.MODELS + : trainingDatasetNotFound + ? APPLICATION_ROUTES.DATASETS + : APPLICATION_ROUTES.HOMEPAGE; + + const fallbackButtonLabel = modelNotFound + ? SHARED_CONTENT.pageNotFound.actionButtons.modelNotFound + : trainingDatasetNotFound + ? SHARED_CONTENT.pageNotFound.actionButtons.trainingDatasetNotFound + : SHARED_CONTENT.pageNotFound.actionButtons.pageNotFound; + const navigate = useNavigate(); return ( @@ -66,22 +79,11 @@ export const PageNotFound = () => {
diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 4560a55f7..ac86c13c1 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -29,7 +29,7 @@ export const APPLICATION_ROUTES = { // base-model start BASE_MODELS_HOME: "/base-models", - BASE_MODEL_DETAILS_PAGE: "/base-models/:id", + BASE_MODEL_DETAILS: "/base-models/:id", // base-model end // Model routes start diff --git a/frontend/src/constants/ui-contents/shared-content.ts b/frontend/src/constants/ui-contents/shared-content.ts index d4367b842..e191f6a5d 100644 --- a/frontend/src/constants/ui-contents/shared-content.ts +++ b/frontend/src/constants/ui-contents/shared-content.ts @@ -141,7 +141,7 @@ export const SHARED_CONTENT: TSharedContent = { baseModelCTA: { title: "Contribute your Base Model", description: - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.", + "Contribute a base model to fAIr and help teams turn imagery into actionable map data, faster and more reliably.", ctaButton: "Contribute", ctaLink: "/base-models", }, @@ -168,11 +168,6 @@ export const SHARED_CONTENT: TSharedContent = { label: "Model Contribution Journey", intro: "Model contribution into fAIr is handled in GITHUB /fAIr-models repository. Here are high level explanation for the contribution four steps and detailed documentation is available when you go to GITHUB", - statusBadgeClasses: { - pending: "bg-status-pending-bg text-status-text", - changes: "bg-status-changes-bg text-status-text", - approved: "bg-status-approved-bg text-status-text", - }, github: { title: "Fair Model github", href: "https://github.com/hotosm/fAIr-models", diff --git a/frontend/src/features/base-models/components/base-model-card.tsx b/frontend/src/features/base-models/components/base-model-card.tsx index dfeda6d99..b94313c78 100644 --- a/frontend/src/features/base-models/components/base-model-card.tsx +++ b/frontend/src/features/base-models/components/base-model-card.tsx @@ -1,7 +1,8 @@ import { Link } from "@/components/ui/link"; import { APPLICATION_ROUTES } from "@/constants"; import { roundNumber } from "@/utils/number-utils"; -import { TBaseModel } from "../data/base-model-data"; +import { TBaseModel } from "@/features/base-models/data/base-model-data"; + type BaseModelCardProps = { model: TBaseModel; diff --git a/frontend/src/features/base-models/components/contribute-model-dialog.tsx b/frontend/src/features/base-models/components/contribute-model-dialog.tsx index f1442e2a9..0da066d27 100644 --- a/frontend/src/features/base-models/components/contribute-model-dialog.tsx +++ b/frontend/src/features/base-models/components/contribute-model-dialog.tsx @@ -18,6 +18,11 @@ type StepProps = { onToggle: () => void; }; +const statusBadgeClasses = { + pending: "bg-status-pending-color text-grey", + changes: "bg-status-changes-color text-grey", + approved: "bg-green-secondary text-grey", +}; const Step: React.FC = ({ stepNumber, title, @@ -31,7 +36,7 @@ const Step: React.FC = ({ className="flex items-center w-full text-left gap-x-3 cursor-pointer" onClick={onToggle} > - + Step {stepNumber}

{title}

@@ -147,11 +152,7 @@ const ContributeModelDialog: React.FC = ({ key={status.label} > @@ -168,10 +169,11 @@ const ContributeModelDialog: React.FC = ({ {/* Go to GitHub Button */}
- +
diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index 446b7a4a4..105c05d8e 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -27,10 +27,8 @@ body { --hot-fair-color-green-primary: #198155; --hot-fair-color-frosted-blue: #f7f9fb; --hot-fair-color-ink: #202325; - --hot-fair-color-status-pending-bg: #fff9da; - --hot-fair-color-status-changes-bg: #ffe3da; - --hot-fair-color-status-approved-bg: #dfffda; - --hot-fair-color-status-text: #6c7072; + --hot-fair-color-status-pending-color: #fff9da; + --hot-fair-color-status-changes-color: #ffe3da; /* Font sizes in rem */ --hot-fair-font-size-extra-large: 4.25rem; diff --git a/frontend/src/types/ui-contents.ts b/frontend/src/types/ui-contents.ts index 845ed596d..856fd0da9 100644 --- a/frontend/src/types/ui-contents.ts +++ b/frontend/src/types/ui-contents.ts @@ -446,11 +446,7 @@ export type TSharedContent = { contributeModelDialog: { label: string; intro: string; - statusBadgeClasses: { - pending: string; - changes: string; - approved: string; - }; + github: { title: string; href: string; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index bbeddb494..5581a1581 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -16,10 +16,8 @@ export default { "green-secondary": "var(--hot-fair-color-green-secondary)", "green-primary": "var(--hot-fair-color-green-primary)", "frosted-blue": "var(--hot-fair-color-frosted-blue)", - "status-pending-bg": "var(--hot-fair-color-status-pending-bg)", - "status-changes-bg": "var(--hot-fair-color-status-changes-bg)", - "status-approved-bg": "var(--hot-fair-color-status-approved-bg)", - "status-text": "var(--hot-fair-color-status-text)", + "status-pending-color": "var(--hot-fair-color-status-pending-color)", + "status-changes-color": "var(--hot-fair-color-status-changes-color)", }, fontFamily: { archivo: "var(--sl-font-sans)", From d8da16f4a7a25291dff1e509a68d771a81210b90 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Wed, 4 Mar 2026 07:57:48 +0100 Subject: [PATCH 04/62] fix: import alias path changed --- .../routes/base-models/base-model-detail.tsx | 5 +- .../routes/base-models/base-models-list.tsx | 4 +- .../components/base-model-card.tsx | 3 +- .../components/base-models-filters.tsx | 5 +- .../components/contribute-model-dialog.tsx | 5 +- frontend/src/types/ui-contents.ts | 2 +- frontend/src/utils/base-model-data.ts | 294 ++++++++++++++++++ 7 files changed, 305 insertions(+), 13 deletions(-) create mode 100644 frontend/src/utils/base-model-data.ts diff --git a/frontend/src/app/routes/base-models/base-model-detail.tsx b/frontend/src/app/routes/base-models/base-model-detail.tsx index 2c710c124..75aea0243 100644 --- a/frontend/src/app/routes/base-models/base-model-detail.tsx +++ b/frontend/src/app/routes/base-models/base-model-detail.tsx @@ -5,12 +5,13 @@ import { DownloadIconNew } from "@/components/ui/icons/download-icon"; import { ToolTip } from "@/components/ui/tooltip"; import { APPLICATION_ROUTES } from "@/constants"; import { ButtonVariant } from "@/enums"; + +import AccuracyDisplay from "@/features/models/components/accuracy-display"; import { BASE_MODELS_DETAIL_DATA, TBaseModelDetail, TBaseModelVariant, -} from "@/features/base-models/data/base-model-data"; -import AccuracyDisplay from "@/features/models/components/accuracy-display"; +} from "@/utils/base-model-data"; import { useEffect, useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; diff --git a/frontend/src/app/routes/base-models/base-models-list.tsx b/frontend/src/app/routes/base-models/base-models-list.tsx index f83858f12..b1ec44d79 100644 --- a/frontend/src/app/routes/base-models/base-models-list.tsx +++ b/frontend/src/app/routes/base-models/base-models-list.tsx @@ -7,7 +7,7 @@ import { BASE_MODELS_DATA, TASK_CATEGORIES, DATE_SORT_OPTIONS, -} from "@/features/base-models/data/base-model-data"; +} from "@/utils/base-model-data"; import { useDialog } from "@/hooks/use-dialog"; import { useMemo } from "react"; import { parseAsString, useQueryStates } from "nuqs"; @@ -101,7 +101,7 @@ export const BaseModelsPage = () => {

- setQueryStates({ q: value })} categoryMenuItems={categoryMenuItems} diff --git a/frontend/src/features/base-models/components/base-model-card.tsx b/frontend/src/features/base-models/components/base-model-card.tsx index b94313c78..de365bdac 100644 --- a/frontend/src/features/base-models/components/base-model-card.tsx +++ b/frontend/src/features/base-models/components/base-model-card.tsx @@ -1,8 +1,7 @@ import { Link } from "@/components/ui/link"; import { APPLICATION_ROUTES } from "@/constants"; +import { TBaseModel } from "@/utils/base-model-data"; import { roundNumber } from "@/utils/number-utils"; -import { TBaseModel } from "@/features/base-models/data/base-model-data"; - type BaseModelCardProps = { model: TBaseModel; diff --git a/frontend/src/features/base-models/components/base-models-filters.tsx b/frontend/src/features/base-models/components/base-models-filters.tsx index 8c401b084..484bffe65 100644 --- a/frontend/src/features/base-models/components/base-models-filters.tsx +++ b/frontend/src/features/base-models/components/base-models-filters.tsx @@ -1,10 +1,7 @@ import { DropDown } from "@/components/ui/dropdown"; import { Switch } from "@/components/ui/form"; import { FilterIcon, ListIcon, SearchIcon } from "@/components/ui/icons"; -import { - DATE_SORT_OPTIONS, - TASK_CATEGORIES, -} from "@/features/base-models/data/base-model-data"; +import { DATE_SORT_OPTIONS, TASK_CATEGORIES } from "@/utils/base-model-data"; type MenuItem = { value: string; diff --git a/frontend/src/features/base-models/components/contribute-model-dialog.tsx b/frontend/src/features/base-models/components/contribute-model-dialog.tsx index 0da066d27..14c4e5eb8 100644 --- a/frontend/src/features/base-models/components/contribute-model-dialog.tsx +++ b/frontend/src/features/base-models/components/contribute-model-dialog.tsx @@ -169,11 +169,12 @@ const ContributeModelDialog: React.FC = ({ {/* Go to GitHub Button */}
- +
diff --git a/frontend/src/types/ui-contents.ts b/frontend/src/types/ui-contents.ts index 856fd0da9..0dd09d056 100644 --- a/frontend/src/types/ui-contents.ts +++ b/frontend/src/types/ui-contents.ts @@ -446,7 +446,7 @@ export type TSharedContent = { contributeModelDialog: { label: string; intro: string; - + github: { title: string; href: string; diff --git a/frontend/src/utils/base-model-data.ts b/frontend/src/utils/base-model-data.ts new file mode 100644 index 000000000..63ba091bc --- /dev/null +++ b/frontend/src/utils/base-model-data.ts @@ -0,0 +1,294 @@ +/** + * Static base model data used until the API is ready. + */ +export type TBaseModel = { + id: number; + name: string; + description: string; + accuracy: number; + author: string; + lastModified: string; + task: string; + version: string; + createdBy: string; + generatedOn: string; + modelWeightsLicense: string; + datasetLicense: string; + dataId: string; +}; + +/** + * Extended detail data for a base model's detail page. + */ +export type TBaseModelDetail = TBaseModel & { + fullTitle: string; + overview: string; + useCases: { + suitable: string[]; + notSuitable: string[]; + }; + performance: string; + limitations: string[]; + architecture: { + baseModel: string; + head: string; + input: string; + tileSizePx: string; + processing: string; + resize: string; + scaling: string; + output: string; + outputDescription: string; + variants: TBaseModelVariant[]; + }; + dataInfo: { + sensor: string; + crs: string; + spatialExtent: string; + temporalExtent: string; + }; + downloadMetadataUrl?: string; +}; + +export type TBaseModelVariant = { + name: string; + classes: string; + notes: string; +}; + +export const BASE_MODELS_DATA: TBaseModel[] = [ + { + id: 1, + name: "RAMP", + description: + "Optimized for faster training with decent accuracy. Best suited for building detection tasks.", + accuracy: 80.98, + author: "Omran Najjar", + lastModified: "25/12/24", + task: "semantic-segmentation", + version: "v1.0", + createdBy: "RAMP (Replicable AI for Microplanning) contributors", + generatedOn: "2026-02-22", + modelWeightsLicense: "TBD", + datasetLicense: "TBD", + dataId: "50", + }, + { + id: 2, + name: "YOLO_V8_V1", + description: + "A well-balanced model offering good accuracy for detecting structures in major areas. Trained by the community.", + accuracy: 80.98, + author: "Omran Najjar", + lastModified: "25/12/24", + task: "instance-segmentation", + version: "v1.0", + createdBy: "Community contributors", + generatedOn: "2026-01-15", + modelWeightsLicense: "TBD", + datasetLicense: "TBD", + dataId: "51", + }, + { + id: 3, + name: "YOLO_V8_V2", + description: + "Our most advanced model. Designed for detecting various features across different areas. Developed in collaboration with Omdena AI.", + accuracy: 80.98, + author: "Omran Najjar", + lastModified: "25/12/24", + task: "object-detection", + version: "v2.0", + createdBy: "Omdena AI collaboration", + generatedOn: "2026-02-10", + modelWeightsLicense: "TBD", + datasetLicense: "TBD", + dataId: "52", + }, +]; + +/** + * Detailed data for base model detail pages. + */ +export const BASE_MODELS_DETAIL_DATA: TBaseModelDetail[] = [ + { + ...BASE_MODELS_DATA[0], + fullTitle: "RAMP Building Footprint Segmentation Model", + overview: + "This model extracts building footprints from high-resolution overhead RGB satellite imagery. It produces per-pixel class masks that can be post-processed into building polygons for micromapping and humanitarian mapping workflows.\n\nThis card describes the reference RAMP segmentation model family: an EfficientNet encoder + U-Net (decoder) semantic segmentation network trained on 256×256 RGB chips. Specific trained checkpoints (weights) vary by AOI/dataset and training configuration.", + useCases: { + suitable: [ + "Building footprint mapping from high-resolution overhead RGB imagery", + "Generating building polygons (GeoJSON) for micromapping workflows", + "Training and fine-tuning building segmentation models for new regions using the same pipeline", + ], + notSuitable: [ + "Non-RGB-only inputs (e.g., SAR-only, multispectral without adapting the model)", + "Low-resolution imagery where buildings are not resolvable at chip scale", + '"Out of the box" global inference without validation (models are strongly data/domain-dependent)', + ], + }, + performance: + "Evaluation metrics (validation): Validation pixel-wise sparse categorical accuracy ≈ 1,000 for a representative binary-building checkpoint. This means that ≈98.9% of pixels in the validation chips were assigned the correct class (building vs background). Because background pixels typically dominate, pixel accuracy can overstate real-extent footprint quality; also report polygon level Precision/Recall/F1 on validation using Intersection-over-Union (IoU) matching at IoU ≥ 0.5 (IoU@0.5) recommended.", + limitations: [ + "Domain shift / transfer risk: Performance can degrade substantially across countries, roof materials, seasons, and label conventions. Validate on representative samples before scaling.", + "Resolution & tiling constraints: The default production/training flow assumes 256 × 256 chips and model-specific preprocessing; changing resolution or chip size requires spatial revalidation.", + "Preprocessing sensitivity: Images are normalized per-chip to [0,1] via division by the chip's max value. This differs from fixed global scaling and can change behavior across datasets.", + "Polygon post processing assumptions: Polygon fusion across tile boundaries and boundary buffering depend on mask conventions (binary vs multi-mask with boundary pixels). Incorrect settings can distort outputs.", + ], + architecture: { + baseModel: "EfficientNet-B0 encoder (imagenet-pretrained)", + head: "U-Net decoder", + input: "GBI GeoTIFF chip (channels-last in model pipeline)", + tileSizePx: "[256, 256]", + processing: "—", + resize: "direct route to output_img_shape when output 256×256", + scaling: "per-chip normalization: float32 / max(pixel_value) → [0 .. 1]", + output: "—", + outputDescription: + "Single-band uint8 mask with class IDs derived by argmax over class probabilities", + variants: [ + { + name: "Binary-mask", + classes: '["background", "buildings"]', + notes: "Used in many configs (num_classes=2).", + }, + { + name: "Multi-mask (4-class)", + classes: '["background", "buildings", "boundary", "close_contact"]', + notes: "Used for boundary-aware training and downstream fusion.", + }, + ], + }, + dataInfo: { + sensor: "High-resolution overhead optical (RGB) satellite imagery", + crs: "Match intent source imagery CRS; polygon outputs are commonly delivered in EPSG:4326", + spatialExtent: "TBD (AOI-dependent)", + temporalExtent: "TBD", + }, + }, + { + ...BASE_MODELS_DATA[1], + fullTitle: "YOLO V8 V1 Building Detection Model", + overview: + "A well-balanced YOLOv8-based model trained by the community. It offers good accuracy for detecting building structures in major urban and suburban areas using high-resolution overhead imagery.\n\nThis model leverages the YOLOv8 architecture for instance segmentation, producing both bounding boxes and pixel-level masks for individual buildings.", + useCases: { + suitable: [ + "Building detection in urban and suburban areas from aerial/satellite imagery", + "Instance-level building segmentation for individual footprint extraction", + "Rapid area-wide building inventory mapping", + ], + notSuitable: [ + "Rural areas with very sparse, small structures", + "SAR or multispectral-only imagery without RGB channels", + "Fine-grained building type classification beyond presence/absence", + ], + }, + performance: + "The model achieves mAP@0.5 scores of approximately 0.78 on validation datasets covering diverse urban environments. Performance may vary based on image resolution, building density, and regional architectural styles.", + limitations: [ + "Performance degrades in areas with densely packed informal settlements where building boundaries are ambiguous.", + "Requires high-resolution imagery (≤0.5m GSD) for optimal performance.", + "Not validated for non-building structure detection tasks.", + ], + architecture: { + baseModel: "YOLOv8 (ultralytics)", + head: "Instance Segmentation Head", + input: "RGB image tiles (640×640 default)", + tileSizePx: "[640, 640]", + processing: "Auto-padding and letterboxing", + resize: "Bilinear interpolation to target size", + scaling: "Normalize to [0, 1]", + output: "Bounding boxes + instance masks", + outputDescription: + "Per-instance bounding boxes with confidence scores and binary segmentation masks", + variants: [ + { + name: "Standard", + classes: '["background", "building"]', + notes: "Default 2-class configuration.", + }, + ], + }, + dataInfo: { + sensor: "High-resolution overhead optical (RGB) satellite imagery", + crs: "EPSG:4326 (WGS84)", + spatialExtent: "Multiple urban areas globally", + temporalExtent: "2020-2025", + }, + }, + { + ...BASE_MODELS_DATA[2], + fullTitle: "YOLO V8 V2 Multi-Feature Detection Model", + overview: + "Our most advanced model, developed in collaboration with Omdena AI. This YOLOv8 v2 model is designed for detecting various features across different geographic areas, including buildings, roads, and other infrastructure.\n\nBuilt on extensive community-contributed training data from multiple countries, it provides robust detection across diverse environments.", + useCases: { + suitable: [ + "Multi-feature detection across diverse geographic regions", + "Building and infrastructure mapping for humanitarian response", + "Large-scale mapping projects requiring consistent detection quality", + ], + notSuitable: [ + "Single-feature specialized detection where a domain-specific model would perform better", + "Very low resolution imagery (> 1m GSD)", + "Real-time video processing applications", + ], + }, + performance: + "Achieves mAP@0.5 of approximately 0.82 across diverse test datasets. The v2 model shows a 5% improvement over v1 in challenging environments such as informal settlements and mixed land-use areas.", + limitations: [ + "Larger model size may impact inference speed on resource-constrained devices.", + "Multi-class detection may produce occasional false positives in complex scenes.", + "Performance in heavily forested or mountainous terrain is not yet fully validated.", + "Requires GPU for efficient batch inference.", + ], + architecture: { + baseModel: "YOLOv8x (ultralytics, extra-large)", + head: "Object Detection Head", + input: "RGB image tiles (640×640)", + tileSizePx: "[640, 640]", + processing: "Mosaic augmentation compatible", + resize: "Adaptive resize with aspect ratio preservation", + scaling: "Normalize to [0, 1]", + output: "Bounding boxes + class labels", + outputDescription: + "Polygonize_and_fuse: convert predicted masks to polygons and fuse across tile boundaries", + variants: [ + { + name: "Standard", + classes: '["background", "building"]', + notes: "Default building detection configuration.", + }, + { + name: "Multi-feature", + classes: '["background", "building", "road", "water"]', + notes: "Extended multi-class detection (experimental).", + }, + ], + }, + dataInfo: { + sensor: "High-resolution overhead optical (RGB) satellite imagery", + crs: "EPSG:4326 (WGS84)", + spatialExtent: "Global (multi-country training data)", + temporalExtent: "2021-2026", + }, + }, +]; + +/** + * Task category options for the category filter dropdown. + */ +export const TASK_CATEGORIES = [ + { label: "All", value: "all" }, + { label: "Semantic Segmentation", value: "semantic-segmentation" }, + { label: "Instance Segmentation", value: "instance-segmentation" }, + { label: "Object Detection", value: "object-detection" }, +]; + +/** + * Date sort options. + */ +export const DATE_SORT_OPTIONS = [ + { label: "Newest First", value: "newest" }, + { label: "Oldest First", value: "oldest" }, +]; From a99506f430c9135d2d3711db6ac8956e390c08e8 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Thu, 5 Mar 2026 23:36:48 +0100 Subject: [PATCH 05/62] feat: complete all base model enhancement --- .../adr-choose-markdown-library/adr1.md | 46 ++++++++ .../routes/base-models/base-model-detail.tsx | 68 +---------- .../routes/base-models/base-models-list.tsx | 96 +++++++++++----- .../src/components/shared/markdown-render.tsx | 20 ++++ .../constants/ui-contents/shared-content.ts | 2 +- .../components/base-models-filters.tsx | 99 ++++++++++------ .../components/contribute-model-dialog.tsx | 19 ++- .../features/base-models/components/index.ts | 4 + .../components/mobile-base-model-filters.tsx | 108 ++++++++++++++++++ .../src/features/base-models/layouts/grid.tsx | 20 ++++ .../src/features/base-models/layouts/index.ts | 2 + .../features/base-models/layouts/table.tsx | 78 +++++++++++++ frontend/src/styles/index.css | 72 +++++++++++- frontend/src/utils/base-model-data.ts | 100 ++++++++++++++++ 14 files changed, 587 insertions(+), 147 deletions(-) create mode 100644 docs/decisions/frontend/architecture/adr-choose-markdown-library/adr1.md create mode 100644 frontend/src/components/shared/markdown-render.tsx create mode 100644 frontend/src/features/base-models/components/index.ts create mode 100644 frontend/src/features/base-models/components/mobile-base-model-filters.tsx create mode 100644 frontend/src/features/base-models/layouts/grid.tsx create mode 100644 frontend/src/features/base-models/layouts/index.ts create mode 100644 frontend/src/features/base-models/layouts/table.tsx diff --git a/docs/decisions/frontend/architecture/adr-choose-markdown-library/adr1.md b/docs/decisions/frontend/architecture/adr-choose-markdown-library/adr1.md new file mode 100644 index 000000000..3ef5988fd --- /dev/null +++ b/docs/decisions/frontend/architecture/adr-choose-markdown-library/adr1.md @@ -0,0 +1,46 @@ +# Architecture Decision Record 1: Use react-markdown with remark-gfm for Rendering Markdown Content + +Date: 05/03/2026 + +# Context + +The base model detail page displays long-form content (overview, use cases, performance, limitations) that was previously rendered using manual paragraph splitting and hardcoded HTML structures. As content grows in complexity — with bold text, inline code, lists, headings, and links — maintaining this as plain strings with custom rendering logic becomes difficult and error-prone. + +We need a solution to render rich, structured text from markdown strings so that content authors can express formatting naturally, while the UI consistently applies the project's design system. + +## Decision Drivers + +- Content flexibility: authors should be able to use headings, bold, lists, code, and links without code changes. +- Consistency: rendered markdown must match the application's existing design system (colors, typography, spacing). +- Minimal bundle impact: the chosen library should be lightweight and avoid unnecessary overhead. +- Existing ecosystem: leverage libraries already present in the project wherever possible. +- Security: HTML should be sanitised by default to prevent XSS from user-supplied content. + +## Considered Options + +- **[react-markdown](https://github.com/remarkjs/react-markdown) + [remark-gfm](https://github.com/remarkjs/remark-gfm)** — A lightweight React component that converts markdown to React elements via the unified/remark ecosystem. `remark-gfm` adds GitHub Flavored Markdown support (tables, strikethrough, task lists, autolinks). Does **not** use `dangerouslySetInnerHTML`; it builds a React virtual DOM tree. Already installed as project dependencies. +- **[markdown-to-jsx](https://www.npmjs.com/package/markdown-to-jsx)** — A single-package alternative that compiles markdown to JSX. Slightly smaller bundle size, but lacks the plugin ecosystem of remark and does not support GFM features without extra work. + +- **Custom rendering logic** — Continue splitting strings on `\n\n` and mapping to `

`, `

    `, `
      ` elements manually. Does not scale as content grows in richness. + +# Decision + +We will use **react-markdown** (v9) with the **remark-gfm** plugin to render all long-form content in the frontend starting with base model detail page. + +Key implementation details: + +1. **Data model simplification**: The separate `overview`, `useCases`, `performance`, and `limitations` fields on `TBaseModelDetail` are consolidated into a single `markdownContent: string` field containing full markdown. +2. **Styling**: The Tailwind CSS `@tailwindcss/typography` plugin's `prose` class is used as the base, with a scoped `.model-detail-prose` CSS class that overrides defaults to match the application's design tokens (colors, font sizes, spacing). +3. **Banner isolation**: The existing banner component's `.prose *` white-text override is scoped to `.prose:not(.model-detail-prose)` so the two contexts do not conflict. + +# Status + +Accepted. + +# Consequences + +- **Positive**: Content is now authored in standard markdown, making it easier to update and maintain. Markdown supports headings, bold, italic, lists, inline code, links, and tables out of the box. +- **Positive**: No new dependencies added — `react-markdown`, `remark-gfm`, and `@tailwindcss/typography` were already in `package.json`. +- **Positive**: Safe by default — `react-markdown` does not use `dangerouslySetInnerHTML` and builds React elements directly. +- **Trade-off**: Content structure is now implicit in the markdown string rather than explicit in the TypeScript type. If specific sections need to be programmatically accessed separately (e.g., extracting just the overview), parsing the markdown would be required. +- **Trade-off**: Custom `.model-detail-prose` CSS styles need to be maintained alongside the design system. If design tokens change, these styles must be updated accordingly. diff --git a/frontend/src/app/routes/base-models/base-model-detail.tsx b/frontend/src/app/routes/base-models/base-model-detail.tsx index 75aea0243..a26c2272e 100644 --- a/frontend/src/app/routes/base-models/base-model-detail.tsx +++ b/frontend/src/app/routes/base-models/base-model-detail.tsx @@ -1,4 +1,5 @@ import { Head } from "@/components/seo"; +import MarkdownViewer from "@/components/shared/markdown-render"; import { BackButton, ButtonWithIcon } from "@/components/ui/button"; import { ChevronDownIcon, InfoIcon } from "@/components/ui/icons"; import { DownloadIconNew } from "@/components/ui/icons/download-icon"; @@ -277,71 +278,10 @@ export const BaseModelDetailPage = () => { {/* Main Content: Two Column Layout */}
      {/* Left Column - Overview */} -
      - {/* Overview */} -
      -

      - Overview -

      - {model.overview.split("\n\n").map((paragraph, i) => ( -

      - {paragraph} -

      - ))} -
      - - {/* Use Cases */} -
      -

      - Use Cases -

      -
      -
      -

      - Suitable for: -

      -
        - {model.useCases.suitable.map((item, i) => ( -
      • {item}
      • - ))} -
      -
      -
      -

      - Not suitable for: -

      -
        - {model.useCases.notSuitable.map((item, i) => ( -
      • {item}
      • - ))} -
      -
      -
      -
      - - {/* Performance */} -
      -

      - Performance -

      -

      {model.performance}

      -
      - - {/* Limitations */} -
      -

      - Limitations -

      -
        - {model.limitations.map((item, i) => ( -
      1. {item}
      2. - ))} -
      -
      -
      + {/* Right Column - Architecture Info */} -
      +
      {architectureRows.map((row) => ( @@ -363,7 +303,7 @@ export const BaseModelDetailPage = () => {
      - +
      {dataInfoRows.map((row) => ( { const { isOpened, openDialog, closeDialog } = useDialog(); + + const { + isOpened: isMobileFiltersOpen, + openDialog: openMobileFilters, + closeDialog: closeMobileFilters, + } = useDialog(); // nuqs-powered search params state - const [ - { q: search, category, date: dateSort, map: mapView }, - setQueryStates, - ] = useQueryStates({ - q: parseAsString.withDefault(""), - category: parseAsString.withDefault("all"), - date: parseAsString.withDefault("newest"), - map: parseAsString.withDefault("false"), - }); - - const isMapViewActive = mapView === "true"; + const [{ q: search, category, date: dateSort, layout }, setQueryStates] = + useQueryStates({ + q: parseAsString.withDefault(""), + category: parseAsString.withDefault("all"), + date: parseAsString.withDefault("newest"), + layout: parseAsString.withDefault(LayoutView.GRID), + }); + const isListView = layout === LayoutView.LIST; // Filter and sort models const filteredModels = useMemo(() => { @@ -75,11 +84,48 @@ export const BaseModelsPage = () => { const selectedDateLabel = DATE_SORT_OPTIONS.find((d) => d.value === dateSort)?.label || "Date"; + const toggleLayout = () => { + setQueryStates({ + layout: isListView ? LayoutView.GRID : LayoutView.LIST, + }); + }; + + const renderContent = () => { + if (filteredModels.length === 0) { + return ( +
      +

      No models found

      +

      + Try adjusting your search or filter criteria. +

      +
      + ); + } + + if (isListView) { + return ( +
      + +
      + ); + } + + return ; + }; return ( <> - + setQueryStates({ category: value })} + setDateSort={(value) => setQueryStates({ date: value })} + />
      {/* Header */}
      @@ -110,25 +156,13 @@ export const BaseModelsPage = () => { selectedDateLabel={selectedDateLabel} setCategory={(value) => setQueryStates({ category: value })} setDateSort={(value) => setQueryStates({ date: value })} - isMapViewActive={isMapViewActive} - setMapView={(value) => setQueryStates({ map: value })} filteredModelsCount={filteredModels.length} + layout={layout} + onToggleLayout={toggleLayout} + onOpenMobileFilters={openMobileFilters} /> - {filteredModels.length === 0 ? ( -
      -

      No models found

      -

      - Try adjusting your search or filter criteria. -

      -
      - ) : ( -
      - {filteredModels.map((model) => ( - - ))} -
      - )} + {renderContent()}
      ); diff --git a/frontend/src/components/shared/markdown-render.tsx b/frontend/src/components/shared/markdown-render.tsx new file mode 100644 index 000000000..4525ad63c --- /dev/null +++ b/frontend/src/components/shared/markdown-render.tsx @@ -0,0 +1,20 @@ +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +type MarkdownViewerProps = { + content: string; + className?: string; +}; + +const MarkdownViewer: React.FC = ({ + content, + className = "", +}) => { + return ( +
      + {content} +
      + ); +}; + +export default MarkdownViewer; diff --git a/frontend/src/constants/ui-contents/shared-content.ts b/frontend/src/constants/ui-contents/shared-content.ts index e191f6a5d..073c64e57 100644 --- a/frontend/src/constants/ui-contents/shared-content.ts +++ b/frontend/src/constants/ui-contents/shared-content.ts @@ -139,7 +139,7 @@ export const SHARED_CONTENT: TSharedContent = { "fAIr is a collaborative project. We welcome all types of experience to join our community on HOTOSM Slack. There is always a room for AI/ML for earth observation expertise, community engagement enthusiastic, academic researcher or student looking for an academic challenge around social impact.", }, baseModelCTA: { - title: "Contribute your Base Model", + title: "Contribute Your Base Model", description: "Contribute a base model to fAIr and help teams turn imagery into actionable map data, faster and more reliably.", ctaButton: "Contribute", diff --git a/frontend/src/features/base-models/components/base-models-filters.tsx b/frontend/src/features/base-models/components/base-models-filters.tsx index 484bffe65..6b61aa2e2 100644 --- a/frontend/src/features/base-models/components/base-models-filters.tsx +++ b/frontend/src/features/base-models/components/base-models-filters.tsx @@ -1,9 +1,15 @@ +import { + SearchIcon, + CategoryIcon, + ListIcon, + FilterIcon, +} from "@/components/ui/icons"; import { DropDown } from "@/components/ui/dropdown"; -import { Switch } from "@/components/ui/form"; -import { FilterIcon, ListIcon, SearchIcon } from "@/components/ui/icons"; -import { DATE_SORT_OPTIONS, TASK_CATEGORIES } from "@/utils/base-model-data"; +import { ToolTip } from "@/components/ui/tooltip"; +import { TASK_CATEGORIES, DATE_SORT_OPTIONS } from "@/utils/base-model-data"; +import { LayoutView } from "@/enums"; -type MenuItem = { +type TMenuItem = { value: string; apiValue: string; }; @@ -11,15 +17,16 @@ type MenuItem = { type BaseModelsFiltersProps = { search: string; setSearch: (value: string | null) => void; - categoryMenuItems: MenuItem[]; - dateMenuItems: MenuItem[]; + categoryMenuItems: TMenuItem[]; + dateMenuItems: TMenuItem[]; selectedCategoryLabel: string; selectedDateLabel: string; setCategory: (value: string | null) => void; setDateSort: (value: string | null) => void; - isMapViewActive: boolean; - setMapView: (value: string | null) => void; filteredModelsCount: number; + layout: string; + onToggleLayout: () => void; + onOpenMobileFilters: () => void; }; const BaseModelsFilters: React.FC = ({ @@ -31,15 +38,19 @@ const BaseModelsFilters: React.FC = ({ selectedDateLabel, setCategory, setDateSort, - isMapViewActive, - setMapView, filteredModelsCount, + layout, + onToggleLayout, + onOpenMobileFilters, }) => { + const isListView = layout === LayoutView.LIST; + return (
      + {/* Search */}
      = ({ />
      + {/* Category Filter — Desktop */}
      = ({ />
      + {/* Date Filter — Desktop */}
      = ({ } />
      - - -
      -
      -
      -

      Map View

      - { - setMapView(isMapViewActive ? null : "true"); - }} - /> + {/* Right side controls */} +
      + {/* Mobile filter button */} +
      + +
      + {/* Desktop layout toggle */} +
      + + +
      -
      + {/* Model count + mobile controls */}

      {filteredModelsCount} Models

      -
      -

      Map View

      - { - setMapView(isMapViewActive ? null : "true"); - }} - /> -
      + {/* Mobile Layout toggle */} + + +
      ); diff --git a/frontend/src/features/base-models/components/contribute-model-dialog.tsx b/frontend/src/features/base-models/components/contribute-model-dialog.tsx index 14c4e5eb8..a71dbbdc2 100644 --- a/frontend/src/features/base-models/components/contribute-model-dialog.tsx +++ b/frontend/src/features/base-models/components/contribute-model-dialog.tsx @@ -14,8 +14,7 @@ type StepProps = { stepNumber: number; title: string; children: React.ReactNode; - isExpanded: boolean; - onToggle: () => void; + defaultOpen?: boolean; }; const statusBadgeClasses = { @@ -27,14 +26,15 @@ const Step: React.FC = ({ stepNumber, title, children, - isExpanded, - onToggle, + defaultOpen = false, }) => { + const [isExpanded, setIsExpanded] = useState(defaultOpen); + return (
      +
      +
      + + ); +}; + +export default MobileBaseModelFiltersDialog; diff --git a/frontend/src/features/base-models/layouts/grid.tsx b/frontend/src/features/base-models/layouts/grid.tsx new file mode 100644 index 000000000..f3b98958f --- /dev/null +++ b/frontend/src/features/base-models/layouts/grid.tsx @@ -0,0 +1,20 @@ +import { TBaseModel } from "@/utils/base-model-data"; +import BaseModelCard from "@/features/base-models/components/base-model-card"; + +type BaseModelGridLayoutProps = { + models: TBaseModel[]; +}; + +const BaseModelGridLayout: React.FC = ({ + models, +}) => { + return ( +
      + {models.map((model) => ( + + ))} +
      + ); +}; + +export default BaseModelGridLayout; diff --git a/frontend/src/features/base-models/layouts/index.ts b/frontend/src/features/base-models/layouts/index.ts new file mode 100644 index 000000000..058382dde --- /dev/null +++ b/frontend/src/features/base-models/layouts/index.ts @@ -0,0 +1,2 @@ +export { default as BaseModelGridLayout } from "./grid"; +export { default as BaseModelTableLayout } from "./table"; diff --git a/frontend/src/features/base-models/layouts/table.tsx b/frontend/src/features/base-models/layouts/table.tsx new file mode 100644 index 000000000..7b8467cf6 --- /dev/null +++ b/frontend/src/features/base-models/layouts/table.tsx @@ -0,0 +1,78 @@ +import { APPLICATION_ROUTES } from "@/constants"; +import { ColumnDef, SortingState } from "@tanstack/react-table"; +import DataTable from "@/components/ui/data-table/data-table"; +import { SortableHeader } from "@/features/models/components/table-header"; +import { roundNumber, truncateString } from "@/utils"; +import { useNavigate } from "react-router-dom"; +import { useState } from "react"; +import { TBaseModel } from "@/utils/base-model-data"; + +const columnDefinitions: ColumnDef[] = [ + { + accessorKey: "id", + header: ({ column }) => , + }, + { + accessorKey: "name", + header: "Model Name", + cell: ({ row }) => ( + + {truncateString(row.getValue("name"), 50)} + + ), + }, + { + accessorKey: "task", + header: "Task", + }, + { + accessorKey: "createdBy", + header: "Created by", + }, + { + accessorKey: "version", + header: "Version", + }, + { + accessorKey: "accuracy", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return {roundNumber(row.getValue("accuracy") ?? 0)}; + }, + }, + { + accessorKey: "lastModified", + header: ({ column }) => ( + + ), + }, +]; + +type BaseModelTableLayoutProps = { + models: TBaseModel[]; +}; + +const BaseModelTableLayout: React.FC = ({ + models, +}) => { + const [sorting, setSorting] = useState([]); + const navigate = useNavigate(); + + const handleClick = (rowData: TBaseModel) => { + navigate(`${APPLICATION_ROUTES.BASE_MODELS_HOME}/${rowData.id}`); + }; + + return ( + + ); +}; + +export default BaseModelTableLayout; diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index 105c05d8e..e37c92f62 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -146,12 +146,82 @@ sl-alert.success::part(base) { /* Tailwind styles begins */ /* Banner content customization starts */ -.prose * { +.prose:not(.model-detail-prose) * { color: var(--hot-fair-color-white) !important; margin: 0px !important; } /* Banner content customization ends */ +/* Model detail page markdown prose styles starts */ +.model-detail-prose { + color: var(--hot-fair-color-dark); +} + +.model-detail-prose h2 { + color: var(--hot-fair-color-dark); + font-weight: 600; + font-size: var(--hot-fair-font-size-title-3); + margin-top: 2rem; + margin-bottom: 1rem; + padding-bottom: 0.5rem; +} + +.model-detail-prose h2:first-child { + margin-top: 0; +} + +.model-detail-prose h3 { + color: var(--hot-fair-color-dark); + font-weight: 500; + font-size: var(--hot-fair-font-size-body-text-1); + margin-top: 1.25rem; + margin-bottom: 0.5rem; +} + +.model-detail-prose p { + color: var(--hot-fair-color-gray); + font-size: var(--hot-fair-font-size-body-text-2base); + line-height: 1.7; + margin-bottom: 0.75rem; +} + +.model-detail-prose ul, +.model-detail-prose ol { + color: var(--hot-fair-color-gray); + font-size: var(--hot-fair-font-size-body-text-2base); + padding-left: 1.5rem; + margin-bottom: 1rem; +} + +.model-detail-prose li { + margin-bottom: 0.375rem; + line-height: 1.6; +} + +.model-detail-prose strong { + color: var(--hot-fair-color-dark); + font-weight: 600; +} + +.model-detail-prose code { + color: var(--hot-fair-color-primary); + background-color: var(--hot-fair-color-off-white); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.875em; +} + +.model-detail-prose a { + color: var(--hot-fair-color-primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +.model-detail-prose a:hover { + opacity: 0.8; +} +/* Model detail page markdown prose styles ends */ + @layer components { .icon { @apply inline-block h-4 w-4; diff --git a/frontend/src/utils/base-model-data.ts b/frontend/src/utils/base-model-data.ts index 63ba091bc..93ec398ef 100644 --- a/frontend/src/utils/base-model-data.ts +++ b/frontend/src/utils/base-model-data.ts @@ -23,6 +23,7 @@ export type TBaseModel = { export type TBaseModelDetail = TBaseModel & { fullTitle: string; overview: string; + markdownContent: string; // Optional field for additional markdown content useCases: { suitable: string[]; notSuitable: string[]; @@ -114,6 +115,37 @@ export const BASE_MODELS_DETAIL_DATA: TBaseModelDetail[] = [ { ...BASE_MODELS_DATA[0], fullTitle: "RAMP Building Footprint Segmentation Model", + markdownContent: `## Overview + +This model extracts building footprints from high-resolution overhead RGB satellite imagery. It produces per-pixel class masks that can be post-processed into building polygons for micromapping and humanitarian mapping workflows. + +This card describes the reference RAMP segmentation model family: an **EfficientNet encoder + U-Net (decoder)** semantic segmentation network trained on 256×256 RGB chips. Specific trained checkpoints (weights) vary by AOI/dataset and training configuration. + +## Use Cases + +### Suitable for + +- Building footprint mapping from high-resolution overhead RGB imagery +- Generating building polygons (GeoJSON) for micromapping workflows +- Training and fine-tuning building segmentation models for new regions using the same pipeline + +### Not suitable for + +- Non-RGB-only inputs (e.g., SAR-only, multispectral without adapting the model) +- Low-resolution imagery where buildings are not resolvable at chip scale +- "Out of the box" global inference without validation (models are strongly data/domain-dependent) + +## Performance + +Evaluation metrics (validation): Validation pixel-wise sparse categorical accuracy ≈ 1,000 for a representative binary-building checkpoint. This means that **≈98.9%** of pixels in the validation chips were assigned the correct class (building vs background). Because background pixels typically dominate, pixel accuracy can overstate real-extent footprint quality; also report polygon level Precision/Recall/F1 on validation using Intersection-over-Union (IoU) matching at IoU ≥ 0.5 (IoU@0.5) recommended. + +## Limitations + +1. **Domain shift / transfer risk:** Performance can degrade substantially across countries, roof materials, seasons, and label conventions. Validate on representative samples before scaling. +2. **Resolution & tiling constraints:** The default production/training flow assumes 256 × 256 chips and model-specific preprocessing; changing resolution or chip size requires spatial revalidation. +3. **Preprocessing sensitivity:** Images are normalized per-chip to \`[0,1]\` via division by the chip's max value. This differs from fixed global scaling and can change behavior across datasets. +4. **Polygon post processing assumptions:** Polygon fusion across tile boundaries and boundary buffering depend on mask conventions (binary vs multi-mask with boundary pixels). Incorrect settings can distort outputs. +`, overview: "This model extracts building footprints from high-resolution overhead RGB satellite imagery. It produces per-pixel class masks that can be post-processed into building polygons for micromapping and humanitarian mapping workflows.\n\nThis card describes the reference RAMP segmentation model family: an EfficientNet encoder + U-Net (decoder) semantic segmentation network trained on 256×256 RGB chips. Specific trained checkpoints (weights) vary by AOI/dataset and training configuration.", useCases: { @@ -135,6 +167,12 @@ export const BASE_MODELS_DETAIL_DATA: TBaseModelDetail[] = [ "Resolution & tiling constraints: The default production/training flow assumes 256 × 256 chips and model-specific preprocessing; changing resolution or chip size requires spatial revalidation.", "Preprocessing sensitivity: Images are normalized per-chip to [0,1] via division by the chip's max value. This differs from fixed global scaling and can change behavior across datasets.", "Polygon post processing assumptions: Polygon fusion across tile boundaries and boundary buffering depend on mask conventions (binary vs multi-mask with boundary pixels). Incorrect settings can distort outputs.", + "Domain shift / transfer risk: Performance can degrade substantially across countries, roof materials, seasons, and label conventions. Validate on representative samples before scaling.", + "Resolution & tiling constraints: The default production/training flow assumes 256 × 256 chips and model-specific preprocessing; changing resolution or chip size requires spatial revalidation.", + "Preprocessing sensitivity: Images are normalized per-chip to [0,1] via division by the chip's max value. This differs from fixed global scaling and can change behavior across datasets.", + "Polygon post processing assumptions: Polygon fusion across tile boundaries and boundary buffering depend on mask conventions (binary vs multi-mask with boundary pixels). Incorrect settings can distort outputs.", + "Domain shift / transfer risk: Performance can degrade substantially across countries, roof materials, seasons, and label conventions. Validate on representative samples before scaling.", + "Resolution & tiling constraints: The default production/training flow assumes 256 × 256 chips and model-specific preprocessing; changing resolution or chip size requires spatial revalidation.", ], architecture: { baseModel: "EfficientNet-B0 encoder (imagenet-pretrained)", @@ -170,6 +208,37 @@ export const BASE_MODELS_DETAIL_DATA: TBaseModelDetail[] = [ { ...BASE_MODELS_DATA[1], fullTitle: "YOLO V8 V1 Building Detection Model", + markdownContent: `## Overview + +This model extracts building footprints from high-resolution overhead RGB satellite imagery. It produces per-pixel class masks that can be post-processed into building polygons for micromapping and humanitarian mapping workflows. + +This card describes the reference RAMP segmentation model family: an **EfficientNet encoder + U-Net (decoder)** semantic segmentation network trained on 256×256 RGB chips. Specific trained checkpoints (weights) vary by AOI/dataset and training configuration. + +## Use Cases + +### Suitable for + +- Building footprint mapping from high-resolution overhead RGB imagery +- Generating building polygons (GeoJSON) for micromapping workflows +- Training and fine-tuning building segmentation models for new regions using the same pipeline + +### Not suitable for + +- Non-RGB-only inputs (e.g., SAR-only, multispectral without adapting the model) +- Low-resolution imagery where buildings are not resolvable at chip scale +- "Out of the box" global inference without validation (models are strongly data/domain-dependent) + +## Performance + +Evaluation metrics (validation): Validation pixel-wise sparse categorical accuracy ≈ 1,000 for a representative binary-building checkpoint. This means that **≈98.9%** of pixels in the validation chips were assigned the correct class (building vs background). Because background pixels typically dominate, pixel accuracy can overstate real-extent footprint quality; also report polygon level Precision/Recall/F1 on validation using Intersection-over-Union (IoU) matching at IoU ≥ 0.5 (IoU@0.5) recommended. + +## Limitations + +1. **Domain shift / transfer risk:** Performance can degrade substantially across countries, roof materials, seasons, and label conventions. Validate on representative samples before scaling. +2. **Resolution & tiling constraints:** The default production/training flow assumes 256 × 256 chips and model-specific preprocessing; changing resolution or chip size requires spatial revalidation. +3. **Preprocessing sensitivity:** Images are normalized per-chip to \`[0,1]\` via division by the chip's max value. This differs from fixed global scaling and can change behavior across datasets. +4. **Polygon post processing assumptions:** Polygon fusion across tile boundaries and boundary buffering depend on mask conventions (binary vs multi-mask with boundary pixels). Incorrect settings can distort outputs. +`, overview: "A well-balanced YOLOv8-based model trained by the community. It offers good accuracy for detecting building structures in major urban and suburban areas using high-resolution overhead imagery.\n\nThis model leverages the YOLOv8 architecture for instance segmentation, producing both bounding boxes and pixel-level masks for individual buildings.", useCases: { @@ -219,6 +288,37 @@ export const BASE_MODELS_DETAIL_DATA: TBaseModelDetail[] = [ }, { ...BASE_MODELS_DATA[2], + markdownContent: `## Overview + +This model extracts building footprints from high-resolution overhead RGB satellite imagery. It produces per-pixel class masks that can be post-processed into building polygons for micromapping and humanitarian mapping workflows. + +This card describes the reference RAMP segmentation model family: an **EfficientNet encoder + U-Net (decoder)** semantic segmentation network trained on 256×256 RGB chips. Specific trained checkpoints (weights) vary by AOI/dataset and training configuration. + +## Use Cases + +### Suitable for + +- Building footprint mapping from high-resolution overhead RGB imagery +- Generating building polygons (GeoJSON) for micromapping workflows +- Training and fine-tuning building segmentation models for new regions using the same pipeline + +### Not suitable for + +- Non-RGB-only inputs (e.g., SAR-only, multispectral without adapting the model) +- Low-resolution imagery where buildings are not resolvable at chip scale +- "Out of the box" global inference without validation (models are strongly data/domain-dependent) + +## Performance + +Evaluation metrics (validation): Validation pixel-wise sparse categorical accuracy ≈ 1,000 for a representative binary-building checkpoint. This means that **≈98.9%** of pixels in the validation chips were assigned the correct class (building vs background). Because background pixels typically dominate, pixel accuracy can overstate real-extent footprint quality; also report polygon level Precision/Recall/F1 on validation using Intersection-over-Union (IoU) matching at IoU ≥ 0.5 (IoU@0.5) recommended. + +## Limitations + +1. **Domain shift / transfer risk:** Performance can degrade substantially across countries, roof materials, seasons, and label conventions. Validate on representative samples before scaling. +2. **Resolution & tiling constraints:** The default production/training flow assumes 256 × 256 chips and model-specific preprocessing; changing resolution or chip size requires spatial revalidation. +3. **Preprocessing sensitivity:** Images are normalized per-chip to \`[0,1]\` via division by the chip's max value. This differs from fixed global scaling and can change behavior across datasets. +4. **Polygon post processing assumptions:** Polygon fusion across tile boundaries and boundary buffering depend on mask conventions (binary vs multi-mask with boundary pixels). Incorrect settings can distort outputs. +`, fullTitle: "YOLO V8 V2 Multi-Feature Detection Model", overview: "Our most advanced model, developed in collaboration with Omdena AI. This YOLOv8 v2 model is designed for detecting various features across different geographic areas, including buildings, roads, and other infrastructure.\n\nBuilt on extensive community-contributed training data from multiple countries, it provides robust detection across diverse environments.", From 5cf3d7dfd336bcd03ce1603a5b81bb2bc1d44173 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Wed, 18 Mar 2026 11:25:28 +0100 Subject: [PATCH 06/62] feat: completed ai predictions page --- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 37 +++ frontend/src/app/router.tsx | 24 +- .../src/app/routes/published-predictions.tsx | 135 +++++++++ .../src/components/layouts/navbar/navbar.tsx | 66 ++++- frontend/src/constants/general.ts | 11 +- frontend/src/constants/routes.ts | 3 + .../components/project-status-dialog.tsx | 2 +- .../published-predictions/api/factory.ts | 13 + .../api/get-published-predictions.ts | 33 +++ .../components/published-prediction-card.tsx | 201 ++++++++++++++ .../published-prediction-detail-dialog.tsx | 257 ++++++++++++++++++ .../published-prediction-details-info.tsx | 56 ++++ .../published-predictions-filters.tsx | 125 +++++++++ .../components/published-predictions-grid.tsx | 80 ++++++ .../components/published-predictions-list.tsx | 127 +++++++++ .../hooks/use-prediction-model-meta.tsx | 55 ++++ .../hooks/use-published-predictions.tsx | 88 ++++++ .../mapswipe-project-active.tsx | 23 +- frontend/src/services/api-routes.ts | 2 + frontend/src/types/api.ts | 7 + frontend/src/types/common.ts | 9 +- 22 files changed, 1330 insertions(+), 25 deletions(-) create mode 100644 frontend/src/app/routes/published-predictions.tsx create mode 100644 frontend/src/features/published-predictions/api/factory.ts create mode 100644 frontend/src/features/published-predictions/api/get-published-predictions.ts create mode 100644 frontend/src/features/published-predictions/components/published-prediction-card.tsx create mode 100644 frontend/src/features/published-predictions/components/published-prediction-detail-dialog.tsx create mode 100644 frontend/src/features/published-predictions/components/published-prediction-details-info.tsx create mode 100644 frontend/src/features/published-predictions/components/published-predictions-filters.tsx create mode 100644 frontend/src/features/published-predictions/components/published-predictions-grid.tsx create mode 100644 frontend/src/features/published-predictions/components/published-predictions-list.tsx create mode 100644 frontend/src/features/published-predictions/hooks/use-prediction-model-meta.tsx create mode 100644 frontend/src/features/published-predictions/hooks/use-published-predictions.tsx diff --git a/frontend/package.json b/frontend/package.json index f01b4cb4c..89f22174f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,7 @@ "framer-motion": "^12.19.1", "geojson": "^0.5.0", "maplibre-gl": "^5.3.1", + "nuqs": "^2.8.9", "pmtiles": "^4.3.0", "react": "19.1.0", "react-confetti-explosion": "^3.0.3", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 22a863e89..ffa8b92ee 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: maplibre-gl: specifier: ^5.3.1 version: 5.3.1 + nuqs: + specifier: ^2.8.9 + version: 2.8.9(react-router-dom@6.26.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-router@6.26.2(react@19.1.0))(react@19.1.0) pmtiles: specifier: ^4.3.0 version: 4.3.0 @@ -933,6 +936,9 @@ packages: resolution: {integrity: sha512-fB9+bPHLg5zVwPbBKEqY3ghyttkJq9RuUzFMTZKweKrNKKDMUACtI8DlMYUqNwpdZMJhf7a0xeak6vFVBSxcbQ==} engines: {node: '>=14.17.0'} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@tailwindcss/typography@0.5.15': resolution: {integrity: sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==} peerDependencies: @@ -2578,6 +2584,27 @@ packages: resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} engines: {node: '>=0.10.0'} + nuqs@2.8.9: + resolution: {integrity: sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ==} + peerDependencies: + '@remix-run/react': '>=2' + '@tanstack/react-router': ^1 + next: '>=14.2.0' + react: '>=18.2.0 || ^19.0.0-0' + react-router: ^5 || ^6 || ^7 + react-router-dom: ^5 || ^6 || ^7 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@tanstack/react-router': + optional: true + next: + optional: true + react-router: + optional: true + react-router-dom: + optional: true + nwsapi@2.2.16: resolution: {integrity: sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==} @@ -4161,6 +4188,8 @@ snapshots: transitivePeerDependencies: - '@types/react' + '@standard-schema/spec@1.0.0': {} + '@tailwindcss/typography@0.5.15(tailwindcss@3.4.13)': dependencies: lodash.castarray: 4.4.0 @@ -6298,6 +6327,14 @@ snapshots: normalize-range@0.1.2: {} + nuqs@2.8.9(react-router-dom@6.26.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-router@6.26.2(react@19.1.0))(react@19.1.0): + dependencies: + '@standard-schema/spec': 1.0.0 + react: 19.1.0 + optionalDependencies: + react-router: 6.26.2(react@19.1.0) + react-router-dom: 6.26.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + nwsapi@2.2.16: {} object-assign@4.1.1: {} diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 3f544b69c..e9daa6d39 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -12,6 +12,7 @@ import { createBrowserRouter, } from "react-router-dom"; import { ModelsProvider } from "@/app/providers/models-provider"; +import { NuqsAdapter } from "nuqs/adapters/react-router/v6"; const router = createBrowserRouter([ { @@ -96,6 +97,23 @@ const router = createBrowserRouter([ }; }, }, + + /** + * AI Predictions route (published predictions). + */ + { + path: APPLICATION_ROUTES.PUBLISHED_PREDICTIONS, + lazy: async () => { + const { PublishedPredictionsPage } = await import( + "@/app/routes/published-predictions" + ); + return { + Component: () => , + }; + }, + }, + + /** * Models details, list and feedbacks route ends. */ @@ -441,5 +459,9 @@ const router = createBrowserRouter([ ]); export const AppRouter = () => { - return ; + return ( + + + + ); }; diff --git a/frontend/src/app/routes/published-predictions.tsx b/frontend/src/app/routes/published-predictions.tsx new file mode 100644 index 000000000..2320e4b00 --- /dev/null +++ b/frontend/src/app/routes/published-predictions.tsx @@ -0,0 +1,135 @@ +import { Head } from "@/components/seo"; +import { PublishedPredictionsFilters } from "@/features/published-predictions/components/published-predictions-filters"; +import { PublishedPredictionsGrid } from "@/features/published-predictions/components/published-predictions-grid"; +import { usePublishedPredictions } from "@/features/published-predictions/hooks/use-published-predictions"; +import { PredictionResultDrawer } from "@/features/user-profile/components/offline-predictions/predictions-results-drawer"; +import { TOfflinePrediction } from "@/types"; +import { useState } from "react"; +import { useDialog } from "@/hooks/use-dialog"; +import PageHeader from "@/features/models/components/header"; +import { MapswipeProjectStatusDialog } from "@/features/mapswipe/components/project-status-dialog"; +import { usePredictionModelsMeta } from "@/features/published-predictions/hooks/use-prediction-model-meta"; + +export const PublishedPredictionsPage = () => { + const { + data, + isPending, + isError, + isPlaceholderData, + refetch, + search, + ordering, + layout, + offset, + setSearch, + setOrdering, + setLayout, + goToNextPage, + goToPrevPage, + } = usePublishedPredictions(); + + const [activePrediction, setActivePrediction] = + useState(null); + + const { + isOpened: isPredictionResultOpened, + openDialog: openPredictionResultDialog, + closeDialog: closePredictionResultDialog, + } = useDialog(); + const predictions = data?.results ?? []; + const { modelNamesById, modelOwnersById } = + usePredictionModelsMeta(predictions); + const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false); + + const handleViewResults = (prediction: TOfflinePrediction) => { + setActivePrediction(prediction); + openPredictionResultDialog(); + }; + + const handleViewDetails = (prediction: TOfflinePrediction) => { + setActivePrediction(prediction); + setIsDetailDialogOpen(true); + }; + + const handleCloseDetail = () => { + setIsDetailDialogOpen(false); + setActivePrediction(null); + }; + + return ( + <> + + + {/* Prediction result drawer (reused from existing feature) */} + {activePrediction && ( + { + setActivePrediction(null); + closePredictionResultDialog(); + }} + /> + )} + + {/* Detail dialog */} + {activePrediction && ( + { + // Can be expanded to open a PM tiles viewer if requested + window.open(pmtiles, "_blank"); + }} + /> + )} + +
      + {/* Page header */} + + + + {/* Filters */} + + + {/* Content */} +
      + +
      +
      + + ); +}; diff --git a/frontend/src/components/layouts/navbar/navbar.tsx b/frontend/src/components/layouts/navbar/navbar.tsx index 249096fa3..69728aa96 100644 --- a/frontend/src/components/layouts/navbar/navbar.tsx +++ b/frontend/src/components/layouts/navbar/navbar.tsx @@ -13,6 +13,7 @@ import { useLocation, useNavigate } from "react-router-dom"; import { UserProfile } from "@/components/layouts"; import { useState } from "react"; import { UserNotifications } from "@/features/user-profile/components/notifications/user-notifications"; +import { DropDown } from "@/components/ui/dropdown"; export const NavBar = () => { const [open, setOpen] = useState(false); @@ -114,29 +115,64 @@ export const NavBar = () => { type NavBarLinksProps = { className: string; setOpen?: (arg: boolean) => void; + isMobile?: boolean; }; const NavBarLinks: React.FC = ({ className, setOpen }) => { const location = useLocation(); + const navigate = useNavigate(); return (
        {navLinks - .filter((link) => link.active) - .map((link, id) => ( -
      • { - //close the drawer after navigating to a new page on mobile - setOpen && setOpen(false); - }} - className={`${styles.navLinkItem} ${location.pathname === link.href && styles.activeLink}`} - > - - {link.title} - -
      • - ))} + .filter((link) => link.href !== "") + .map((link, id) => { + const isActive = + location.pathname.includes(link.href) || + (link.children?.some( + (child) => location.pathname.includes(child.href), + ) ?? + false); + + return ( +
      • { + //close the drawer after navigating to a new page on mobile + if (!link.children) { + setOpen && setOpen(false); + } + }} + className={`${styles.navLinkItem} ${isActive && styles.activeLink} ${link.children ? 'flex items-center' : ''}`} + > + {link.children ? ( + + {link.title} + + } + menuItems={link.children?.map((child) => ({ + value: child.title, + name: child.title, + className: "!uppercase hover:bg-gray-50", + onClick: (e: any) => { + e?.stopPropagation(); + navigate(child.href); + setOpen?.(false); + }, + }))} + /> + ) : ( + + {link.title} + + )} +
      • + ); + })}
      ); }; diff --git a/frontend/src/constants/general.ts b/frontend/src/constants/general.ts index 668ee7242..ff2ace066 100644 --- a/frontend/src/constants/general.ts +++ b/frontend/src/constants/general.ts @@ -8,11 +8,20 @@ export const navLinks: TNavBarLinks = [ href: APPLICATION_ROUTES.MODELS, active: true, }, - { title: SHARED_CONTENT.navbar.routes.exploreDatasets, href: APPLICATION_ROUTES.DATASETS, active: true, + children: [ + { + title: "Datasets", + href: APPLICATION_ROUTES.DATASETS, + }, + { + title: "AI Predictions", + href: APPLICATION_ROUTES.PUBLISHED_PREDICTIONS, + }, + ], }, { title: SHARED_CONTENT.navbar.routes.learn, diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index c29be2e37..37c570672 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -62,6 +62,9 @@ export const APPLICATION_ROUTES = { PROFILE_MODELS: "/profile/models", PROFILE_DATASETS: "/profile/datasets", PROFILE_OFFLINE_PREDICTIONS: "/profile/prediction-requests", + + // Published Predictions + PUBLISHED_PREDICTIONS: "/published-predictions", }; export const HOT_PRIVACY_POLICY_URL: string = "https://www.hotosm.org/privacy"; diff --git a/frontend/src/features/mapswipe/components/project-status-dialog.tsx b/frontend/src/features/mapswipe/components/project-status-dialog.tsx index 935fa5acf..b07dd7a9a 100644 --- a/frontend/src/features/mapswipe/components/project-status-dialog.tsx +++ b/frontend/src/features/mapswipe/components/project-status-dialog.tsx @@ -146,7 +146,7 @@ export const MapswipeProjectStatusDialog = ({

      {truncateString(data?.name, 400)} -

      + { + return queryOptions({ + queryKey: ["published-predictions", searchQuery, ordering, offset], + queryFn: () => getPublishedPredictions(searchQuery, ordering, offset), + }); +}; diff --git a/frontend/src/features/published-predictions/api/get-published-predictions.ts b/frontend/src/features/published-predictions/api/get-published-predictions.ts new file mode 100644 index 000000000..15bf9615b --- /dev/null +++ b/frontend/src/features/published-predictions/api/get-published-predictions.ts @@ -0,0 +1,33 @@ +import { PAGE_LIMIT } from "@/components/shared"; +import { API_ENDPOINTS, apiClient } from "@/services"; +import { TOfflinePrediction } from "@/types"; + +export type PublishedPredictionsResponse = { + count: number; + next: string | null; + previous: string | null; + results: TOfflinePrediction[]; + hasNext: boolean; + hasPrev: boolean; +}; + +export const getPublishedPredictions = async ( + searchQuery?: string, + ordering: string = "-id", + offset?: number, +): Promise => { + const res = await apiClient.get(API_ENDPOINTS.GET_PUBLISHED_PREDICTIONS, { + params: { + published: true, + search: searchQuery, + ordering, + offset, + limit: PAGE_LIMIT, + }, + }); + return { + ...res.data, + hasNext: res.data.next !== null, + hasPrev: res.data.previous !== null, + }; +}; diff --git a/frontend/src/features/published-predictions/components/published-prediction-card.tsx b/frontend/src/features/published-predictions/components/published-prediction-card.tsx new file mode 100644 index 000000000..16039f8de --- /dev/null +++ b/frontend/src/features/published-predictions/components/published-prediction-card.tsx @@ -0,0 +1,201 @@ +import { Badge } from "@/components/ui/badge"; +import { DropDown } from "@/components/ui/dropdown"; +import { ElipsisIcon } from "@/components/ui/icons"; +import { BASE_API_URL } from "@/config"; +import { DropdownPlacement } from "@/enums"; +import { MapSwipeProjectIsActive } from "@/features/user-profile/components/offline-predictions/mapswipe-project-active"; +import useCopyToClipboard from "@/hooks/use-clipboard"; +import { useDropdownMenu } from "@/hooks/use-dropdown-menu"; +import { API_ENDPOINTS } from "@/services"; +import { TOfflinePrediction } from "@/types"; +import { extractDatePart, formatDate, showSuccessToast } from "@/utils"; +import { PublishedPredictionDetailsInfo } from "./published-prediction-details-info"; + +type PublishedPredictionCardProps = { + prediction: TOfflinePrediction; + onViewResults: (prediction: TOfflinePrediction) => void; + onViewDetails: (prediction: TOfflinePrediction) => void; + modelNamesById: Record; + modelOwnersById: Record; +}; + +export const PublishedPredictionCard = ({ + prediction, + onViewResults, + onViewDetails, + modelNamesById, + modelOwnersById, +}: PublishedPredictionCardProps) => { + const { copyToClipboard } = useCopyToClipboard(); + const { dropdownRef } = useDropdownMenu(); + + const title = prediction.description || `Prediction ${prediction.id}`; + + const handleDetailsInfo = () => { + dropdownRef.current?.show(); + }; + const modelUsed = + modelNamesById[prediction.config.model_id] ?? prediction.config.model_id; + + const createdBy = modelOwnersById[prediction.config.model_id]; + + return ( + <> +
      + {/* Card header */} +
      +
      +

      + {title} +

      + e.stopPropagation()} + className="rounded-lg px-2 items-center flex shrink-0" + > + + + } + className="text-left" + distance={10} + placement={DropdownPlacement.BOTTOM_END} + menuItems={[ + { + name: "Download Results", + value: "Download Results", + subMenuItems: [ + { + name: "As Points", + value: "As Points", + onClick: () => { + const downloadUrl = + BASE_API_URL + + API_ENDPOINTS.DOWNLOAD_PREDICTION_RESULTS_POINTS_LABELS_FILE_( + prediction.id, + prediction.config.folder, + ); + window.open(downloadUrl, "_blank"); + }, + }, + { + name: "As Polygons", + value: "As Polygons", + onClick: () => { + const downloadUrl = + BASE_API_URL + + API_ENDPOINTS.DOWNLOAD_PREDICTION_LABELS_FILE( + prediction.id, + prediction.config.folder, + ); + window.open(downloadUrl, "_blank"); + }, + }, + ], + }, + { + name: "View Results", + value: "View Results", + onClick: () => onViewResults(prediction), + }, + { + name: "Copy Result Link", + value: "Copy Result Link", + onClick: async () => { + await copyToClipboard( + BASE_API_URL + + API_ENDPOINTS.DOWNLOAD_PREDICTION_LABELS_FILE( + prediction.id, + prediction.config.folder, + ), + ); + showSuccessToast("Copied results link to clipboard!"); + }, + }, + ...(prediction.mapswipe_id + ? [ + { + name: "View MapSwipe Project", + value: "View MapSwipe Project", + onClick: () => onViewDetails(prediction), + }, + ] + : []), + { + name: "Copy Imagery Link", + value: "Copy Imagery Link", + onClick: async () => { + await copyToClipboard(prediction.config.source); + showSuccessToast("Copied imagery link to clipboard!"); + }, + }, + { + name: "Details", + value: "Details", + onClick: (e) => { + e.stopPropagation(); + handleDetailsInfo(); + }, + }, + ]} + /> +
      +
      + + ID: {prediction.id} + + + onViewDetails(prediction)} + /> +
      +
      + + {/* Card body */} +
      +
      +
      +

      Model Used:

      + {modelUsed} +
      +
      +

      + Date Published:{" "} + + {prediction.published_at + ? formatDate( + extractDatePart(prediction.published_at as string), + ) + : "-"} + +

      +
      +
      +
      + + {/* Added the */} +
      + +
      +
      + + ); +}; diff --git a/frontend/src/features/published-predictions/components/published-prediction-detail-dialog.tsx b/frontend/src/features/published-predictions/components/published-prediction-detail-dialog.tsx new file mode 100644 index 000000000..12b6c1592 --- /dev/null +++ b/frontend/src/features/published-predictions/components/published-prediction-detail-dialog.tsx @@ -0,0 +1,257 @@ +import { Badge } from "@/components/ui/badge"; +import { ButtonWithIcon } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { DropDown } from "@/components/ui/dropdown"; +import { CopyButton } from "@/components/ui/copy-button"; +import { + CloudDownloadIcon, + ExternalLinkIcon, + FilledCalendarIcon, + MapIcon, + DatabaseIcon, + ZoomInIcon, + CloseIcon, +} from "@/components/ui/icons"; +import { BASE_API_URL } from "@/config"; +import { ButtonVariant, SHOELACE_SIZES } from "@/enums"; +import { API_ENDPOINTS } from "@/services"; +import { TBadgeVariants, TOfflinePrediction } from "@/types"; +import { formatDate, formatNumber, truncateString } from "@/utils"; + +type PublishedPredictionDetailDialogProps = { + prediction: TOfflinePrediction; + isOpen: boolean; + onClose: () => void; +}; + +type InfoCardProps = { + icon: React.ReactNode; + info?: string | React.ReactNode; + variant?: "default" | "red" | "green"; + className?: string; +}; + +const InfoBlock = ({ + icon, + info, + variant = "default", + className = "cursor-default", +}: InfoCardProps) => { + return ( + +
      +
      + {icon} +
      + {info} +
      +
      + ); +}; + +export const PublishedPredictionDetailDialog = ({ + prediction, + isOpen, + onClose, +}: PublishedPredictionDetailDialogProps) => { + const title = prediction.description || `Prediction ${prediction.id}`; + const featureCount = prediction.result?.count ?? 0; + + return ( + +
      + {/* Header Map Area */} +
      + + Map Preview + +
      + + {/* Details Content */} +
      +
      +

      + {truncateString(title, 400)} +

      + + Published + +
      + +
      +
      +
      + } + info={`${formatNumber(featureCount)} Features`} + /> + } + info={`Model: ${prediction.config.model_id || "-"}`} + /> + {prediction.config.zoom_level && ( + } + info={`Zoom: ${prediction.config.zoom_level}`} + /> + )} + {prediction.published_at && ( + } + info={formatDate(prediction.published_at)} + /> + )} +
      + + +
      + +
      +
      +

      + Date Submitted:{" "} + + {prediction.created_at + ? formatDate(prediction.created_at) + : "-"} + +

      + {prediction.config.source && ( +

      + Imagery Source:{" "} + + View Link + +

      + )} +
      + + + } + distance={4} + menuItems={[ + { + name: "As Points", + value: "As Points", + onClick: (e) => { + e.stopPropagation(); + window.open( + BASE_API_URL + + API_ENDPOINTS.DOWNLOAD_PREDICTION_RESULTS_POINTS_LABELS_FILE_( + prediction.id, + prediction.config.folder + ), + "_blank" + ); + }, + }, + { + name: "As Polygons", + value: "As Polygons", + onClick: (e) => { + e.stopPropagation(); + window.open( + BASE_API_URL + + API_ENDPOINTS.DOWNLOAD_PREDICTION_LABELS_FILE( + prediction.id, + prediction.config.folder + ), + "_blank" + ); + }, + }, + ]} + /> +
      + +
      +
      +

      + Prediction overview +

      +

      + This prediction includes bounding boxes or polygon detections extracted + by the AI model. Download the results as GeoJSON for Points or Polygons. +

      +
      + + {(prediction.description || prediction.config.source) && ( +
      +

      + Prediction description +

      +

      + {prediction.description || "No specific description provided for this prediction."} +

      +
      + )} +
      +
      +
      +
      +
      + ); +}; diff --git a/frontend/src/features/published-predictions/components/published-prediction-details-info.tsx b/frontend/src/features/published-predictions/components/published-prediction-details-info.tsx new file mode 100644 index 000000000..d7ad619ef --- /dev/null +++ b/frontend/src/features/published-predictions/components/published-prediction-details-info.tsx @@ -0,0 +1,56 @@ +import { DropDown } from "@/components/ui/dropdown"; +import { DropdownPlacement } from "@/enums"; +import { TOfflinePrediction } from "@/types"; +import { formatDate, formatNumber } from "@/utils"; +import { SlDropdown } from "@shoelace-style/shoelace"; +import { MutableRefObject } from "react"; + + + + +export const PublishedPredictionDetailsInfo = ({ + prediction, + modelUsed, + createdBy, + dropdownRef, + placement = DropdownPlacement.BOTTOM_END, +}: { + prediction: TOfflinePrediction; + modelUsed: string; + createdBy: string; + dropdownRef?: MutableRefObject; + placement?: DropdownPlacement; +}) => { + const featureCount = prediction.result?.count ?? 0; + + const publishedDate = prediction.published_at + ? formatDate(prediction.published_at) + : "-"; + return ( + +
      + + Date Published: {publishedDate} + + + Created by: {createdBy} + + + Prediction Count:{" "} + {formatNumber(featureCount)} + + + Model Used: {modelUsed} + +
      +
      + ); +}; diff --git a/frontend/src/features/published-predictions/components/published-predictions-filters.tsx b/frontend/src/features/published-predictions/components/published-predictions-filters.tsx new file mode 100644 index 000000000..4390f9e4c --- /dev/null +++ b/frontend/src/features/published-predictions/components/published-predictions-filters.tsx @@ -0,0 +1,125 @@ +import { Input } from "@/components/ui/form"; +import { SearchIcon } from "@/components/ui/icons"; +import { DropDown } from "@/components/ui/dropdown"; +import { ChevronDownIcon } from "@/components/ui/icons"; +import { SHOELACE_SIZES } from "@/enums"; +import { PAGE_LIMIT } from "@/components/shared"; +import { ORDERING_OPTIONS } from "@/features/published-predictions/hooks/use-published-predictions"; + +type PublishedPredictionsFiltersProps = { + search: string; + onSearchChange: (value: string) => void; + ordering: string; + onOrderingChange: (value: string) => void; + layout?: string; + onLayoutChange: (value: string) => void; + totalCount: number; + offset: number; + hasNextPage: boolean; + hasPrevPage: boolean; + onNextPage: () => void; + onPrevPage: () => void; + isPlaceholderData: boolean; +}; + +export const PublishedPredictionsFilters = ({ + search, + onSearchChange, + ordering, + onOrderingChange, + totalCount, + offset, + hasNextPage, + hasPrevPage, + onNextPage, + onPrevPage, + isPlaceholderData, +}: PublishedPredictionsFiltersProps) => { + const orderingMenuItems = ORDERING_OPTIONS.map((opt) => ({ + value: opt.label, + apiValue: opt.value, + })); + + const selectedOrderingLabel = + ORDERING_OPTIONS.find((o) => o.value === ordering)?.label ?? "Sort by"; + + + const endIndex = + offset + PAGE_LIMIT < totalCount ? offset + PAGE_LIMIT : totalCount; + + return ( +
      + {/* Search bar row */} +
      +
      + + ) => + onSearchChange(e.target.value) + } + value={search} + placeholder="Search" + className="w-full outline-none border-none focus:outline-none focus:ring-0" + size={SHOELACE_SIZES.MEDIUM} + disableOutline + /> +
      +
      + + {/* Count, sort, pagination, layout row */} +
      +

      + {totalCount} Prediction{totalCount !== 1 ? "s" : ""} +

      + +
      + {/* Sort */} + { + const opt = ORDERING_OPTIONS.find( + (o) => o.label === selectedLabel, + ); + if (opt) onOrderingChange(opt.value); + }} + withCheckbox + defaultSelectedItem={selectedOrderingLabel} + triggerComponent={ +

      + Sort by +

      + } + /> + + {/* Pagination */} +
      +

      + + {totalCount > 0 ? offset + 1 : 0}-{endIndex} + {" "} + of {totalCount} +

      + + +
      + + +
      +
      +
      + ); +}; diff --git a/frontend/src/features/published-predictions/components/published-predictions-grid.tsx b/frontend/src/features/published-predictions/components/published-predictions-grid.tsx new file mode 100644 index 000000000..201bc0d8a --- /dev/null +++ b/frontend/src/features/published-predictions/components/published-predictions-grid.tsx @@ -0,0 +1,80 @@ +import { Button } from "@/components/ui/button"; +import { NoTrainingAreaIcon } from "@/components/ui/icons"; +import { TOfflinePrediction } from "@/types"; +import { PublishedPredictionCard } from "./published-prediction-card"; + +type PublishedPredictionsGridProps = { + data: TOfflinePrediction[]; + isPending: boolean; + modelNamesById: Record; + modelOwnersById: Record; + isError: boolean; + refetch: () => void; + onViewResults: (prediction: TOfflinePrediction) => void; + onViewDetails: (prediction: TOfflinePrediction) => void; +}; + +const GridSkeleton = () => ( +
      + {Array.from({ length: 12 }).map((_, index) => ( +
      + ))} +
      +); + +export const PublishedPredictionsGrid = ({ + data, + isPending, + isError, + refetch, + modelNamesById, + modelOwnersById, + onViewResults, + onViewDetails, +}: PublishedPredictionsGridProps) => { + if (isPending) { + return ; + } + + if (isError) { + return ( +
      +

      + Error loading published predictions. +

      + +
      + ); + } + + if (data.length === 0) { + return ( +
      + +

      + No published predictions found. +

      +
      + ); + } + + return ( +
      + {data.map((prediction) => ( + + ))} +
      + ); +}; diff --git a/frontend/src/features/published-predictions/components/published-predictions-list.tsx b/frontend/src/features/published-predictions/components/published-predictions-list.tsx new file mode 100644 index 000000000..154cd1141 --- /dev/null +++ b/frontend/src/features/published-predictions/components/published-predictions-list.tsx @@ -0,0 +1,127 @@ +import { Button } from "@/components/ui/button"; +import { NoTrainingAreaIcon, MapIcon } from "@/components/ui/icons"; +import { TOfflinePrediction } from "@/types"; +import { formatDate, formatNumber } from "@/utils"; + +type PublishedPredictionsListProps = { + data: TOfflinePrediction[]; + isPending: boolean; + isError: boolean; + refetch: () => void; + onViewResults: (prediction: TOfflinePrediction) => void; + onViewDetails: (prediction: TOfflinePrediction) => void; +}; + +const ListSkeleton = () => ( +
      + {Array.from({ length: 8 }).map((_, index) => ( +
      + ))} +
      +); + +export const PublishedPredictionsList = ({ + data, + isPending, + isError, + refetch, + onViewResults, + onViewDetails, +}: PublishedPredictionsListProps) => { + if (isPending) { + return ; + } + + if (isError) { + return ( +
      +

      + Error loading published predictions. +

      + +
      + ); + } + + if (data.length === 0) { + return ( +
      + +

      + No published predictions found. +

      +
      + ); + } + + return ( +
      + + + + + + + + + + + + + {data.map((prediction) => { + const title = + prediction.description || `Prediction ${prediction.id}`; + return ( + onViewDetails(prediction)} + data-testid={`published-prediction-row-${prediction.id}`} + > + + + + + + + + ); + })} + +
      NameIDFeaturesModelPublishedActions
      + {title} + + + {prediction.id} + + + + + {formatNumber(prediction.result?.count ?? 0)} + + + {prediction.config.model_id || "-"} + + {prediction.published_at + ? formatDate(prediction.published_at, true) + : "-"} + + +
      +
      + ); +}; diff --git a/frontend/src/features/published-predictions/hooks/use-prediction-model-meta.tsx b/frontend/src/features/published-predictions/hooks/use-prediction-model-meta.tsx new file mode 100644 index 000000000..d9af31a12 --- /dev/null +++ b/frontend/src/features/published-predictions/hooks/use-prediction-model-meta.tsx @@ -0,0 +1,55 @@ +import { useMemo } from "react"; +import { useQueries } from "@tanstack/react-query"; +import { getModelDetails } from "@/features/models/api/get-models"; +import { QUERY_KEYS } from "@/services"; +import { TOfflinePrediction } from "@/types"; + +type PredictionModelMeta = { + modelNamesById: Record; + modelOwnersById: Record; +}; + +export const usePredictionModelsMeta = ( + predictions: TOfflinePrediction[], +): PredictionModelMeta => { + const modelIds = useMemo( + () => + Array.from( + new Set( + predictions + .map((prediction) => prediction.config.model_id) + .filter(Boolean), + ), + ), + [predictions], + ); + + const modelDetailsQueries = useQueries({ + queries: modelIds.map((modelId) => ({ + queryKey: [QUERY_KEYS.MODEL_DETAILS(modelId)], + queryFn: () => getModelDetails(modelId), + enabled: !!modelId, + })), + }); + + return useMemo( + () => + modelIds.reduce( + (acc, modelId, index) => { + const modelInfo = modelDetailsQueries[index]?.data; + if (modelInfo?.name) { + acc.modelNamesById[modelId] = modelInfo.name; + } + if (modelInfo?.user?.username) { + acc.modelOwnersById[modelId] = modelInfo.user.username; + } + return acc; + }, + { + modelNamesById: {}, + modelOwnersById: {}, + } as PredictionModelMeta, + ), + [modelIds, modelDetailsQueries], + ); +}; diff --git a/frontend/src/features/published-predictions/hooks/use-published-predictions.tsx b/frontend/src/features/published-predictions/hooks/use-published-predictions.tsx new file mode 100644 index 000000000..b0c24c885 --- /dev/null +++ b/frontend/src/features/published-predictions/hooks/use-published-predictions.tsx @@ -0,0 +1,88 @@ +import useDebounce from "@/hooks/use-debounce"; +import { useCallback } from "react"; +import { parseAsInteger, parseAsString, useQueryStates } from "nuqs"; +import { useQuery } from "@tanstack/react-query"; +import { getPublishedPredictionsQueryOptions } from "@/features/published-predictions/api/factory"; +import { PAGE_LIMIT } from "@/components/shared"; +import { LayoutView } from "@/enums"; + +const ORDERING_OPTIONS = [ + { label: "Newest First", value: "-id" }, + { label: "Oldest First", value: "id" }, + { label: "Recently Published", value: "-published_at" }, +] as const; + +const usePublishedPredictionsSearchParams = () => { + return useQueryStates({ + q: parseAsString.withDefault(""), + orderBy: parseAsString.withDefault("-id"), + offset: parseAsInteger.withDefault(0), + layout: parseAsString.withDefault(LayoutView.GRID), + }); +}; + +export const usePublishedPredictions = () => { + const [params, setParams] = usePublishedPredictionsSearchParams(); + + const debouncedSearch = useDebounce(params.q, 300); + + const { isPending, isError, data, refetch, isPlaceholderData } = useQuery({ + ...getPublishedPredictionsQueryOptions( + debouncedSearch.length > 0 ? debouncedSearch : undefined, + params.orderBy, + params.offset > 0 ? params.offset : undefined, + ), + }); + + const setSearch = useCallback( + (value: string) => { + void setParams({ q: value, offset: 0 }); + }, + [setParams], + ); + + const setOrdering = useCallback( + (value: string) => { + void setParams({ orderBy: value, offset: 0 }); + }, + [setParams], + ); + + const setLayout = useCallback( + (value: string) => { + void setParams({ layout: value }); + }, + [setParams], + ); + + const goToNextPage = useCallback(() => { + if (data?.hasNext) { + void setParams({ offset: params.offset + PAGE_LIMIT }); + } + }, [data?.hasNext, params.offset, setParams]); + + const goToPrevPage = useCallback(() => { + if (data?.hasPrev) { + void setParams({ offset: Math.max(params.offset - PAGE_LIMIT, 0) }); + } + }, [data?.hasPrev, params.offset, setParams]); + + return { + data, + isPending, + isError, + isPlaceholderData, + refetch, + search: params.q, + ordering: params.orderBy, + layout: params.layout, + offset: params.offset, + setSearch, + setOrdering, + setLayout, + goToNextPage, + goToPrevPage, + }; +}; + +export { ORDERING_OPTIONS }; diff --git a/frontend/src/features/user-profile/components/offline-predictions/mapswipe-project-active.tsx b/frontend/src/features/user-profile/components/offline-predictions/mapswipe-project-active.tsx index 34a0e6226..943a69d1e 100644 --- a/frontend/src/features/user-profile/components/offline-predictions/mapswipe-project-active.tsx +++ b/frontend/src/features/user-profile/components/offline-predictions/mapswipe-project-active.tsx @@ -1,13 +1,16 @@ import { MapSwipeLogo } from "@/assets/svgs"; +import { Button } from "@/components/ui/button"; import { Image } from "@/components/ui/image"; import { ToolTip } from "@/components/ui/tooltip"; export const MapSwipeProjectIsActive = ({ MapSwipeId, isCard, + onClick, }: { MapSwipeId: string; isCard?: boolean; + onClick?: () => void; }) => { return ( @@ -15,11 +18,21 @@ export const MapSwipeProjectIsActive = ({ - MapSwipe Icon + {onClick ? ( + + ) : ( + MapSwipe Icon + )} ) : isCard ? null : ( "-" diff --git a/frontend/src/services/api-routes.ts b/frontend/src/services/api-routes.ts index 342344677..12c10c821 100644 --- a/frontend/src/services/api-routes.ts +++ b/frontend/src/services/api-routes.ts @@ -26,6 +26,8 @@ export const API_ENDPOINTS = { CREATE_OFFLINE_PREDICTION: "prediction/", GET_OFFLINE_PREDICTIONS: "prediction/", UPDATE_OFFLINE_PREDICTION: (id: number) => `prediction/${id}/`, + GET_PUBLISHED_PREDICTIONS: "prediction/", + // Feedbacks CREATE_FEEDBACK: "feedback/", diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index d34b2a1bd..e028484f0 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -265,6 +265,7 @@ export type TOfflinePrediction = { created_at: string; started_at: string | null; finished_at: string | null; + published_at: string | null; status: ModelTrainingStatus; task_id: string; mapswipe_id: string | null; @@ -274,6 +275,12 @@ export type TOfflinePrediction = { published: boolean; result: null | { count: number; + output?: { + aois: string; + pmtiles: string; + predictions: string; + predictions_points: string; + }; }; }; diff --git a/frontend/src/types/common.ts b/frontend/src/types/common.ts index a376d6e7c..ab230777d 100644 --- a/frontend/src/types/common.ts +++ b/frontend/src/types/common.ts @@ -23,7 +23,7 @@ export type TQueryParams = Record< string | number | boolean | undefined >; -export type TBadgeVariants = "green" | "red" | "yellow" | "blue" | "default"; +export type TBadgeVariants = "green" | "red" | "yellow" | "blue" | "default"; export type ButtonSize = "large" | "medium" | "small"; @@ -55,8 +55,13 @@ export type TNavBarLinks = { title: string; href: string; active: boolean; + children?: { title: string; href: string }[]; }[]; - +export type NavLinkItem = { + title: string; + href: string; + children?: { title: string; href: string }[]; +}; // Extending with shoelace properties. export type TCSSWithVars = React.CSSProperties & { "--size"?: string; From ee59d3b8d4849565eb50b8fb1dba57510a4c2964 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Wed, 18 Mar 2026 11:28:01 +0100 Subject: [PATCH 07/62] chore: changed heading text --- frontend/src/app/router.tsx | 3 +- .../src/app/routes/published-predictions.tsx | 4 +-- .../src/components/layouts/navbar/navbar.tsx | 8 ++--- .../components/project-status-dialog.tsx | 2 +- .../published-prediction-detail-dialog.tsx | 29 ++++++++++++------- .../published-prediction-details-info.tsx | 5 +--- .../published-predictions-filters.tsx | 13 +++++---- .../mapswipe-project-active.tsx | 1 - frontend/src/types/common.ts | 2 +- 9 files changed, 35 insertions(+), 32 deletions(-) diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index e9daa6d39..0092b8d29 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -98,7 +98,7 @@ const router = createBrowserRouter([ }, }, - /** + /** * AI Predictions route (published predictions). */ { @@ -113,7 +113,6 @@ const router = createBrowserRouter([ }, }, - /** * Models details, list and feedbacks route ends. */ diff --git a/frontend/src/app/routes/published-predictions.tsx b/frontend/src/app/routes/published-predictions.tsx index 2320e4b00..6a0e56b3b 100644 --- a/frontend/src/app/routes/published-predictions.tsx +++ b/frontend/src/app/routes/published-predictions.tsx @@ -58,7 +58,7 @@ export const PublishedPredictionsPage = () => { return ( <> - + {/* Prediction result drawer (reused from existing feature) */} {activePrediction && ( @@ -91,7 +91,7 @@ export const PublishedPredictionsPage = () => { {/* Page header */} { type NavBarLinksProps = { className: string; setOpen?: (arg: boolean) => void; - isMobile?: boolean; + isMobile?: boolean; }; const NavBarLinks: React.FC = ({ className, setOpen }) => { @@ -129,8 +129,8 @@ const NavBarLinks: React.FC = ({ className, setOpen }) => { .map((link, id) => { const isActive = location.pathname.includes(link.href) || - (link.children?.some( - (child) => location.pathname.includes(child.href), + (link.children?.some((child) => + location.pathname.includes(child.href), ) ?? false); @@ -143,7 +143,7 @@ const NavBarLinks: React.FC = ({ className, setOpen }) => { setOpen && setOpen(false); } }} - className={`${styles.navLinkItem} ${isActive && styles.activeLink} ${link.children ? 'flex items-center' : ''}`} + className={`${styles.navLinkItem} ${isActive && styles.activeLink} ${link.children ? "flex items-center" : ""}`} > {link.children ? (

      {truncateString(data?.name, 400)} -

      +
      -
      +
      {icon}
      {info} @@ -89,7 +91,10 @@ export const PublishedPredictionDetailDialog = ({

      {truncateString(title, 400)}

      - + Published
      @@ -129,7 +134,7 @@ export const PublishedPredictionDetailDialog = ({ BASE_API_URL + API_ENDPOINTS.DOWNLOAD_PREDICTION_LABELS_FILE( prediction.id, - prediction.config.folder + prediction.config.folder, ) } tooltipContent="Copy Result Link" @@ -202,9 +207,9 @@ export const PublishedPredictionDetailDialog = ({ BASE_API_URL + API_ENDPOINTS.DOWNLOAD_PREDICTION_RESULTS_POINTS_LABELS_FILE_( prediction.id, - prediction.config.folder + prediction.config.folder, ), - "_blank" + "_blank", ); }, }, @@ -217,9 +222,9 @@ export const PublishedPredictionDetailDialog = ({ BASE_API_URL + API_ENDPOINTS.DOWNLOAD_PREDICTION_LABELS_FILE( prediction.id, - prediction.config.folder + prediction.config.folder, ), - "_blank" + "_blank", ); }, }, @@ -233,18 +238,20 @@ export const PublishedPredictionDetailDialog = ({ Prediction overview

      - This prediction includes bounding boxes or polygon detections extracted - by the AI model. Download the results as GeoJSON for Points or Polygons. + This prediction includes bounding boxes or polygon detections + extracted by the AI model. Download the results as GeoJSON for + Points or Polygons.

      - + {(prediction.description || prediction.config.source) && (

      Prediction description

      - {prediction.description || "No specific description provided for this prediction."} + {prediction.description || + "No specific description provided for this prediction."}

      )} diff --git a/frontend/src/features/published-predictions/components/published-prediction-details-info.tsx b/frontend/src/features/published-predictions/components/published-prediction-details-info.tsx index d7ad619ef..07e616096 100644 --- a/frontend/src/features/published-predictions/components/published-prediction-details-info.tsx +++ b/frontend/src/features/published-predictions/components/published-prediction-details-info.tsx @@ -5,9 +5,6 @@ import { formatDate, formatNumber } from "@/utils"; import { SlDropdown } from "@shoelace-style/shoelace"; import { MutableRefObject } from "react"; - - - export const PublishedPredictionDetailsInfo = ({ prediction, modelUsed, @@ -22,7 +19,7 @@ export const PublishedPredictionDetailsInfo = ({ placement?: DropdownPlacement; }) => { const featureCount = prediction.result?.count ?? 0; - + const publishedDate = prediction.published_at ? formatDate(prediction.published_at) : "-"; diff --git a/frontend/src/features/published-predictions/components/published-predictions-filters.tsx b/frontend/src/features/published-predictions/components/published-predictions-filters.tsx index 4390f9e4c..231bb59d6 100644 --- a/frontend/src/features/published-predictions/components/published-predictions-filters.tsx +++ b/frontend/src/features/published-predictions/components/published-predictions-filters.tsx @@ -43,7 +43,6 @@ export const PublishedPredictionsFilters = ({ const selectedOrderingLabel = ORDERING_OPTIONS.find((o) => o.value === ordering)?.label ?? "Sort by"; - const endIndex = offset + PAGE_LIMIT < totalCount ? offset + PAGE_LIMIT : totalCount; @@ -88,7 +87,7 @@ export const PublishedPredictionsFilters = ({

      Sort by

      - } + } /> {/* Pagination */} @@ -105,7 +104,9 @@ export const PublishedPredictionsFilters = ({ disabled={!hasPrevPage} onClick={onPrevPage} > - +
      - -
      diff --git a/frontend/src/features/user-profile/components/offline-predictions/mapswipe-project-active.tsx b/frontend/src/features/user-profile/components/offline-predictions/mapswipe-project-active.tsx index 943a69d1e..36f9e12c2 100644 --- a/frontend/src/features/user-profile/components/offline-predictions/mapswipe-project-active.tsx +++ b/frontend/src/features/user-profile/components/offline-predictions/mapswipe-project-active.tsx @@ -1,5 +1,4 @@ import { MapSwipeLogo } from "@/assets/svgs"; -import { Button } from "@/components/ui/button"; import { Image } from "@/components/ui/image"; import { ToolTip } from "@/components/ui/tooltip"; diff --git a/frontend/src/types/common.ts b/frontend/src/types/common.ts index ab230777d..aa9ba8d9d 100644 --- a/frontend/src/types/common.ts +++ b/frontend/src/types/common.ts @@ -23,7 +23,7 @@ export type TQueryParams = Record< string | number | boolean | undefined >; -export type TBadgeVariants = "green" | "red" | "yellow" | "blue" | "default"; +export type TBadgeVariants = "green" | "red" | "yellow" | "blue" | "default"; export type ButtonSize = "large" | "medium" | "small"; From ce095d8f5ca02391d3d13e2c2412374d7cd0d2fa Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Wed, 18 Mar 2026 11:43:41 +0100 Subject: [PATCH 08/62] fix: replace success modal with toast --- .../publish-prediction-flow.tsx | 48 ++++--------------- 1 file changed, 10 insertions(+), 38 deletions(-) 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 11346bfad..e3b110401 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 @@ -1,13 +1,8 @@ -import { ConfirmationModal, SuccessModal } from "@/components/shared/modals"; +import { ConfirmationModal } from "@/components/shared/modals"; import { WarningIcon } from "@/components/ui/icons/warning-icon"; -import { WavyCheckIcon } from "@/components/ui/icons/wavy-check-icon"; import { usePublishPrediction } from "@/features/user-profile/api/predictions"; -import { showErrorToast } from "@/utils"; -import { useState } from "react"; +import { showErrorToast, showSuccessToast } from "@/utils"; -type ModalStep = "confirming" | "success"; - -type PublishAction = "publish" | "unpublish"; type PublishPredictionFlowProps = { predictionId: number; @@ -22,17 +17,16 @@ export const PublishPredictionFlow = ({ isOpen, onClose, }: PublishPredictionFlowProps) => { - const [step, setStep] = useState("confirming"); - /* - stored the action locally because `isPublished` from props may still contain - the previous value when the success modal renders after the mutation. This - prevents the UI from briefly showing the wrong success message. -*/ - const [action, setAction] = useState(null); - const { mutate, isPending } = usePublishPrediction({ mutationConfig: { - onSuccess: () => setStep("success"), + onSuccess: () => { + showSuccessToast( + isPublished + ? "Prediction retracted successfully." + : "Prediction published successfully.", + ); + onClose(); + }, onError: (err) => { onClose(); showErrorToast(err); @@ -41,14 +35,10 @@ export const PublishPredictionFlow = ({ }); const handleClose = () => { - setStep("confirming"); - setAction(null); onClose(); }; const handleConfirm = () => { - const nextAction: PublishAction = isPublished ? "unpublish" : "publish"; - setAction(nextAction); mutate({ predictionId, published: !isPublished, @@ -56,24 +46,6 @@ export const PublishPredictionFlow = ({ }; if (!isOpen) return null; - if (step === "success") { - return ( - - -
      - } - /> - ); - } return ( Date: Wed, 18 Mar 2026 11:46:15 +0100 Subject: [PATCH 09/62] fix: replace success modal with toast --- .../components/offline-predictions/publish-prediction-flow.tsx | 1 - 1 file changed, 1 deletion(-) 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 e3b110401..7e92d80de 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 @@ -3,7 +3,6 @@ import { WarningIcon } from "@/components/ui/icons/warning-icon"; import { usePublishPrediction } from "@/features/user-profile/api/predictions"; import { showErrorToast, showSuccessToast } from "@/utils"; - type PublishPredictionFlowProps = { predictionId: number; isPublished: boolean; From a79ea070a0398d45b6cbc0137cd1fe26dfcd5543 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Sun, 22 Mar 2026 20:43:24 +0100 Subject: [PATCH 10/62] feat: added published predictions table and map view feature --- .../src/app/routes/published-predictions.tsx | 114 +++++++++-- .../published-predictions/api/factory.ts | 15 +- .../api/get-published-predictions.ts | 13 +- .../components/published-prediction-card.tsx | 15 +- .../published-prediction-details-info.tsx | 6 +- .../published-predictions-filters.tsx | 51 ++++- .../components/published-predictions-grid.tsx | 23 ++- .../components/published-predictions-map.tsx | 189 ++++++++++++++++++ .../published-predictions-table.tsx | 181 +++++++++++++++++ .../hooks/use-prediction-model-meta.tsx | 55 ----- .../hooks/use-published-predictions.tsx | 120 ++++++++--- frontend/src/services/api-routes.ts | 1 + frontend/src/types/api.ts | 6 +- 13 files changed, 658 insertions(+), 131 deletions(-) create mode 100644 frontend/src/features/published-predictions/components/published-predictions-map.tsx create mode 100644 frontend/src/features/published-predictions/components/published-predictions-table.tsx delete mode 100644 frontend/src/features/published-predictions/hooks/use-prediction-model-meta.tsx diff --git a/frontend/src/app/routes/published-predictions.tsx b/frontend/src/app/routes/published-predictions.tsx index 6a0e56b3b..0adfd562a 100644 --- a/frontend/src/app/routes/published-predictions.tsx +++ b/frontend/src/app/routes/published-predictions.tsx @@ -3,12 +3,19 @@ import { PublishedPredictionsFilters } from "@/features/published-predictions/co import { PublishedPredictionsGrid } from "@/features/published-predictions/components/published-predictions-grid"; import { usePublishedPredictions } from "@/features/published-predictions/hooks/use-published-predictions"; import { PredictionResultDrawer } from "@/features/user-profile/components/offline-predictions/predictions-results-drawer"; -import { TOfflinePrediction } from "@/types"; -import { useState } from "react"; +import { FeatureCollection, TOfflinePrediction } from "@/types"; +import { useEffect, useState } from "react"; import { useDialog } from "@/hooks/use-dialog"; import PageHeader from "@/features/models/components/header"; import { MapswipeProjectStatusDialog } from "@/features/mapswipe/components/project-status-dialog"; -import { usePredictionModelsMeta } from "@/features/published-predictions/hooks/use-prediction-model-meta"; +import { + useScrollToElement, + useScrollToTop, +} from "@/hooks/use-scroll-to-element"; +import { LayoutView } from "@/enums"; +import { PublishedPredictionsListLayout } from "@/features/published-predictions/components/published-predictions-table"; +import { Spinner } from "@/components/ui/spinner"; +import { PublishedPredictionsMap } from "@/features/published-predictions/components/published-predictions-map"; export const PublishedPredictionsPage = () => { const { @@ -20,12 +27,20 @@ export const PublishedPredictionsPage = () => { search, ordering, layout, + query, offset, + setMapView, setSearch, setOrdering, setLayout, + mapViewIsActive, + mapData, + isMapDataPending, + isMapDataError, goToNextPage, goToPrevPage, + setPredictionId, + clearAllFilters, } = usePublishedPredictions(); const [activePrediction, setActivePrediction] = @@ -36,10 +51,13 @@ export const PublishedPredictionsPage = () => { openDialog: openPredictionResultDialog, closeDialog: closePredictionResultDialog, } = useDialog(); - const predictions = data?.results ?? []; - const { modelNamesById, modelOwnersById } = - usePredictionModelsMeta(predictions); + const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false); + const mapViewElementId = "published-predictions-map-view"; + const { scrollToElement } = useScrollToElement(mapViewElementId); + const { scrollToTop } = useScrollToTop(); + + const isListView = layout === LayoutView.LIST; const handleViewResults = (prediction: TOfflinePrediction) => { setActivePrediction(prediction); @@ -56,6 +74,72 @@ export const PublishedPredictionsPage = () => { setActivePrediction(null); }; + useEffect(() => { + if (mapViewIsActive) { + scrollToElement(); + } else { + scrollToTop(); + } + }, [mapViewIsActive, scrollToElement, scrollToTop]); + + const renderContent = () => { + if (mapViewIsActive) { + return ( +
      +
      + +
      +
      + {isMapDataPending || + isMapDataError || + !mapData || + mapData.features.length === 0 ? ( +
      + +
      + ) : ( + + )} +
      +
      + ); + } + + return ( +
      + {isListView ? ( + + ) : ( + + )} +
      + ); + }; return ( <> @@ -89,7 +173,6 @@ export const PublishedPredictionsPage = () => {
      {/* Page header */} - { ordering={ordering} onOrderingChange={setOrdering} layout={layout} + onMapViewChange={setMapView} + mapViewIsActive={mapViewIsActive} + clearAllFilters={clearAllFilters} onLayoutChange={setLayout} + query={query} totalCount={data?.count ?? 0} offset={offset} hasNextPage={data?.hasNext ?? false} @@ -117,18 +204,7 @@ export const PublishedPredictionsPage = () => { /> {/* Content */} -
      - -
      +
      {renderContent()}
      ); diff --git a/frontend/src/features/published-predictions/api/factory.ts b/frontend/src/features/published-predictions/api/factory.ts index b31999d53..aa12373c3 100644 --- a/frontend/src/features/published-predictions/api/factory.ts +++ b/frontend/src/features/published-predictions/api/factory.ts @@ -1,13 +1,22 @@ import { queryOptions } from "@tanstack/react-query"; -import { getPublishedPredictions } from "@/features/published-predictions/api/get-published-predictions"; +import { getPublishedPredictions, getPublishedPredictionsMapData } from "@/features/published-predictions/api/get-published-predictions"; export const getPublishedPredictionsQueryOptions = ( searchQuery?: string, ordering?: string, offset?: number, + id?: number, + ) => { return queryOptions({ - queryKey: ["published-predictions", searchQuery, ordering, offset], - queryFn: () => getPublishedPredictions(searchQuery, ordering, offset), + queryKey: ["published-predictions", searchQuery, ordering, offset, id], + queryFn: () => getPublishedPredictions(searchQuery, ordering, offset, id), }); }; + +export const getPublishedPredictionsMapDataQueryOptions = () => { + return queryOptions({ + queryKey: ["published-predictions-centroid"], + queryFn: getPublishedPredictionsMapData, + }); +}; \ No newline at end of file diff --git a/frontend/src/features/published-predictions/api/get-published-predictions.ts b/frontend/src/features/published-predictions/api/get-published-predictions.ts index 15bf9615b..64bf55363 100644 --- a/frontend/src/features/published-predictions/api/get-published-predictions.ts +++ b/frontend/src/features/published-predictions/api/get-published-predictions.ts @@ -1,6 +1,6 @@ import { PAGE_LIMIT } from "@/components/shared"; import { API_ENDPOINTS, apiClient } from "@/services"; -import { TOfflinePrediction } from "@/types"; +import { FeatureCollection, TOfflinePrediction } from "@/types"; export type PublishedPredictionsResponse = { count: number; @@ -15,6 +15,7 @@ export const getPublishedPredictions = async ( searchQuery?: string, ordering: string = "-id", offset?: number, + id?: number, ): Promise => { const res = await apiClient.get(API_ENDPOINTS.GET_PUBLISHED_PREDICTIONS, { params: { @@ -23,6 +24,7 @@ export const getPublishedPredictions = async ( ordering, offset, limit: PAGE_LIMIT, + id, }, }); return { @@ -31,3 +33,12 @@ export const getPublishedPredictions = async ( hasPrev: res.data.previous !== null, }; }; + +export const getPublishedPredictionsMapData = + async (): Promise => { + const res = await apiClient.get( + API_ENDPOINTS.GET_PUBLISHED_PREDICTIONS_CENTROIDS, + ); + + return res.data; + }; diff --git a/frontend/src/features/published-predictions/components/published-prediction-card.tsx b/frontend/src/features/published-predictions/components/published-prediction-card.tsx index 16039f8de..d6ff9d410 100644 --- a/frontend/src/features/published-predictions/components/published-prediction-card.tsx +++ b/frontend/src/features/published-predictions/components/published-prediction-card.tsx @@ -15,29 +15,20 @@ type PublishedPredictionCardProps = { prediction: TOfflinePrediction; onViewResults: (prediction: TOfflinePrediction) => void; onViewDetails: (prediction: TOfflinePrediction) => void; - modelNamesById: Record; - modelOwnersById: Record; }; export const PublishedPredictionCard = ({ prediction, onViewResults, onViewDetails, - modelNamesById, - modelOwnersById, }: PublishedPredictionCardProps) => { const { copyToClipboard } = useCopyToClipboard(); const { dropdownRef } = useDropdownMenu(); - const title = prediction.description || `Prediction ${prediction.id}`; const handleDetailsInfo = () => { dropdownRef.current?.show(); }; - const modelUsed = - modelNamesById[prediction.config.model_id] ?? prediction.config.model_id; - - const createdBy = modelOwnersById[prediction.config.model_id]; return ( <> @@ -169,7 +160,7 @@ export const PublishedPredictionCard = ({

      Model Used:

      - {modelUsed} + {prediction.model_name}

      @@ -190,8 +181,8 @@ export const PublishedPredictionCard = ({

      diff --git a/frontend/src/features/published-predictions/components/published-prediction-details-info.tsx b/frontend/src/features/published-predictions/components/published-prediction-details-info.tsx index 07e616096..0c7d72e83 100644 --- a/frontend/src/features/published-predictions/components/published-prediction-details-info.tsx +++ b/frontend/src/features/published-predictions/components/published-prediction-details-info.tsx @@ -3,7 +3,7 @@ import { DropdownPlacement } from "@/enums"; import { TOfflinePrediction } from "@/types"; import { formatDate, formatNumber } from "@/utils"; import { SlDropdown } from "@shoelace-style/shoelace"; -import { MutableRefObject } from "react"; +import { MutableRefObject, ReactNode } from "react"; export const PublishedPredictionDetailsInfo = ({ prediction, @@ -11,12 +11,14 @@ export const PublishedPredictionDetailsInfo = ({ createdBy, dropdownRef, placement = DropdownPlacement.BOTTOM_END, + triggerComponent, }: { prediction: TOfflinePrediction; modelUsed: string; createdBy: string; dropdownRef?: MutableRefObject; placement?: DropdownPlacement; + triggerComponent?: ReactNode }) => { const featureCount = prediction.result?.count ?? 0; @@ -29,7 +31,7 @@ export const PublishedPredictionDetailsInfo = ({ ref={dropdownRef} disableCheveronIcon placement={placement} - triggerComponent={null} + triggerComponent={triggerComponent} className="text-right" distance={10} > diff --git a/frontend/src/features/published-predictions/components/published-predictions-filters.tsx b/frontend/src/features/published-predictions/components/published-predictions-filters.tsx index 231bb59d6..b36c1815b 100644 --- a/frontend/src/features/published-predictions/components/published-predictions-filters.tsx +++ b/frontend/src/features/published-predictions/components/published-predictions-filters.tsx @@ -1,10 +1,14 @@ import { Input } from "@/components/ui/form"; -import { SearchIcon } from "@/components/ui/icons"; +import { CategoryIcon, ListIcon, SearchIcon } from "@/components/ui/icons"; import { DropDown } from "@/components/ui/dropdown"; import { ChevronDownIcon } from "@/components/ui/icons"; -import { SHOELACE_SIZES } from "@/enums"; -import { PAGE_LIMIT } from "@/components/shared"; +import { LayoutView, SHOELACE_SIZES } from "@/enums"; +import { ClearFilters, PAGE_LIMIT } from "@/components/shared"; import { ORDERING_OPTIONS } from "@/features/published-predictions/hooks/use-published-predictions"; +import { ToolTip } from "@/components/ui/tooltip"; +import ShowMapToggle from "@/components/shared/show-map-toggle"; +import { SEARCH_PARAMS } from "@/utils/search-params"; +import { TQueryParams } from "@/types"; type PublishedPredictionsFiltersProps = { search: string; @@ -15,10 +19,14 @@ type PublishedPredictionsFiltersProps = { onLayoutChange: (value: string) => void; totalCount: number; offset: number; + query: TQueryParams; hasNextPage: boolean; + clearAllFilters: () => void; hasPrevPage: boolean; + onMapViewChange: (value: boolean) => void; onNextPage: () => void; onPrevPage: () => void; + mapViewIsActive: boolean; isPlaceholderData: boolean; }; @@ -31,18 +39,28 @@ export const PublishedPredictionsFilters = ({ offset, hasNextPage, hasPrevPage, + clearAllFilters, onNextPage, + layout, + onLayoutChange, onPrevPage, isPlaceholderData, + onMapViewChange, + mapViewIsActive, + query, }: PublishedPredictionsFiltersProps) => { const orderingMenuItems = ORDERING_OPTIONS.map((opt) => ({ value: opt.label, apiValue: opt.value, })); + const isGridView = layout === LayoutView.GRID; const selectedOrderingLabel = ORDERING_OPTIONS.find((o) => o.value === ordering)?.label ?? "Sort by"; - + const mapToggleQuery: TQueryParams = { + [SEARCH_PARAMS.layout]: layout, + [SEARCH_PARAMS.mapIsActive]: mapViewIsActive, + }; const endIndex = offset + PAGE_LIMIT < totalCount ? offset + PAGE_LIMIT : totalCount; @@ -63,6 +81,7 @@ export const PublishedPredictionsFilters = ({ disableOutline />
      +
      {/* Count, sort, pagination, layout row */} @@ -119,6 +138,30 @@ export const PublishedPredictionsFilters = ({ />
      + { + onMapViewChange(Boolean(params[SEARCH_PARAMS.mapIsActive])); + }} + /> + {/* Layout toggle */} + + +
      diff --git a/frontend/src/features/published-predictions/components/published-predictions-grid.tsx b/frontend/src/features/published-predictions/components/published-predictions-grid.tsx index 201bc0d8a..43578da30 100644 --- a/frontend/src/features/published-predictions/components/published-predictions-grid.tsx +++ b/frontend/src/features/published-predictions/components/published-predictions-grid.tsx @@ -6,16 +6,15 @@ import { PublishedPredictionCard } from "./published-prediction-card"; type PublishedPredictionsGridProps = { data: TOfflinePrediction[]; isPending: boolean; - modelNamesById: Record; - modelOwnersById: Record; isError: boolean; refetch: () => void; onViewResults: (prediction: TOfflinePrediction) => void; onViewDetails: (prediction: TOfflinePrediction) => void; + isMapView?: boolean; }; -const GridSkeleton = () => ( -
      +const GridSkeleton = ({isMapview }: {isMapview?: boolean}) => ( +
      {Array.from({ length: 12 }).map((_, index) => (
      { if (isPending) { - return ; + return ; } if (isError) { @@ -64,13 +62,16 @@ export const PublishedPredictionsGrid = ({ } return ( -
      +
      {data.map((prediction) => ( diff --git a/frontend/src/features/published-predictions/components/published-predictions-map.tsx b/frontend/src/features/published-predictions/components/published-predictions-map.tsx new file mode 100644 index 000000000..c0abf5cc5 --- /dev/null +++ b/frontend/src/features/published-predictions/components/published-predictions-map.tsx @@ -0,0 +1,189 @@ +import { FeatureCollection } from "@/types"; +import { Map } from "maplibre-gl"; +import { MapComponent } from "@/components/map"; +import { MapMarkerIcon } from "@/assets/images"; +import { useCallback, useEffect } from "react"; +import { useMapInstance } from "@/hooks/use-map-instance"; + +const mapSourceName = "published-predictions"; +const licensedFonts = ["Noto Sans Regular"]; + +let markerIcon = new Image(17, 20); +markerIcon.src = MapMarkerIcon; + +const getPredictionLabel = (feature: FeatureCollection["features"][number]) => { + const featureId = getPredictionId(feature); + + return featureId ? `#${featureId}` : ""; +}; + +const getPredictionId = (feature: FeatureCollection["features"][number]) => { + const properties = + feature.properties && typeof feature.properties === "object" + ? (feature.properties as Record) + : {}; + + return ( + properties.id ?? properties.pid ?? properties.prediction_id ?? feature.id + ); +}; + +const maplibreLayerDefn = ( + map: Map, + mapResults: FeatureCollection, + handleClickOnPredictionID: (clickedId: string) => void, +) => { + map.addImage("publishedPredictionMarker", markerIcon, { + // @ts-expect-error bad type definition + width: 15, + height: 15, + data: markerIcon, + }); + + map.addSource(mapSourceName, { + type: "geojson", + data: mapResults, + cluster: true, + clusterRadius: 35, + }); + + map.addLayer({ + id: "publishedPredictionsClusters", + filter: ["has", "point_count"], + type: "circle", + source: mapSourceName, + layout: {}, + paint: { + "circle-color": "rgba(214, 63, 64,0.8)", + "circle-radius": [ + "step", + ["get", "point_count"], + 14, + 10, + 22, + 50, + 30, + 500, + 37, + ], + }, + }); + + map.addLayer({ + id: "published-predictions-cluster-count", + type: "symbol", + source: mapSourceName, + filter: ["has", "point_count"], + layout: { + "text-field": "{point_count_abbreviated}", + "text-font": licensedFonts, + "text-size": 16, + }, + paint: { + "text-color": "#FFF", + "text-halo-width": 10, + "text-halo-blur": 1, + }, + }); + + map.addLayer({ + id: "published-predictions-unclustered-points", + type: "symbol", + source: mapSourceName, + filter: ["!", ["has", "point_count"]], + layout: { + "icon-image": "publishedPredictionMarker", + "text-field": ["get", "label"], + "text-font": licensedFonts, + "text-offset": [0, 0.6], + "text-anchor": "top", + }, + paint: { + "text-color": "#2c3038", + "text-halo-width": 1, + "text-halo-color": "#fff", + }, + }); + + map.on("mouseenter", "published-predictions-unclustered-points", () => { + map.getCanvas().style.cursor = "pointer"; + }); + + map.on("mouseleave", "published-predictions-unclustered-points", () => { + map.getCanvas().style.cursor = ""; + }); + + map.on("click", "published-predictions-unclustered-points", (e: any) => { + const clickedFeature = e.features?.[0]; + const clickedPredictionId = clickedFeature + ? getPredictionId(clickedFeature) + : undefined; + + if (clickedPredictionId) { + handleClickOnPredictionID(String(clickedPredictionId)); + } + }); +}; + +type PublishedPredictionsMapProps = { + mapResults: FeatureCollection; + setPredictionId: (predictionId: string) => void; +}; + +export const PublishedPredictionsMap: React.FC< + PublishedPredictionsMapProps +> = ({ mapResults, setPredictionId }) => { + const { map, mapContainerRef } = useMapInstance(false, false); + + const handleClickOnPredictionID = useCallback( + (clickedPredictionId: string) => { + setPredictionId(clickedPredictionId); + }, + [setPredictionId], + ); + + const getMapResultsWithLabels = useCallback((): FeatureCollection => { + return { + ...mapResults, + features: mapResults.features.map((feature) => ({ + ...feature, + properties: { + ...(feature.properties ?? {}), + label: getPredictionLabel(feature), + }, + })), + }; + }, [mapResults]); + + useEffect(() => { + if (!map || !mapResults) return; + + const someResultsReady = + mapResults.features && mapResults.features.length > 0; + const mapReadyPredictionsReady = + map.isStyleLoaded() && + map.getSource(mapSourceName) === undefined && + someResultsReady; + + const labeledMapResults = getMapResultsWithLabels(); + + if (mapReadyPredictionsReady) { + maplibreLayerDefn(map, labeledMapResults, handleClickOnPredictionID); + } else { + map.on("load", () => + maplibreLayerDefn(map, labeledMapResults, handleClickOnPredictionID), + ); + } + }, [map, mapResults, getMapResultsWithLabels, handleClickOnPredictionID]); + + return ( +
      + +
      + ); +}; diff --git a/frontend/src/features/published-predictions/components/published-predictions-table.tsx b/frontend/src/features/published-predictions/components/published-predictions-table.tsx new file mode 100644 index 000000000..423d27ebe --- /dev/null +++ b/frontend/src/features/published-predictions/components/published-predictions-table.tsx @@ -0,0 +1,181 @@ +import { Button } from "@/components/ui/button"; +import { DataTable } from "@/components/ui/data-table"; +import { ToolTip } from "@/components/ui/tooltip"; +import { NoTrainingAreaIcon, MapIcon, InfoIcon } from "@/components/ui/icons"; +import { ColumnDef, SortingState } from "@tanstack/react-table"; +import { SortableHeader } from "@/features/models/components/table-header"; +import { TableSkeleton } from "@/features/models/components/skeletons"; +import { TOfflinePrediction } from "@/types"; +import { + extractDatePart, + formatDate, + formatNumber, + truncateString, +} from "@/utils"; +import { MapSwipeProjectIsActive } from "@/features/user-profile/components/offline-predictions/mapswipe-project-active"; +import { PublishedPredictionDetailsInfo } from "./published-prediction-details-info"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { DropdownPlacement } from "@/enums"; + +type PublishedPredictionsListProps = { + data: TOfflinePrediction[]; + isPending: boolean; + isError: boolean; + refetch: () => void; + onViewDetails: (prediction: TOfflinePrediction) => void; + onViewResults: (prediction: TOfflinePrediction) => void; +}; +// const { dropdownRef } = useDropdownMenu(); + +const getPredictionTitle = (prediction: TOfflinePrediction) => + prediction.description; + +const getModelUsed = (prediction: TOfflinePrediction) => prediction.model_name; + +const columnDefinitions = ( + onViewMapswipe: (prediction: TOfflinePrediction) => void, +): ColumnDef[] => [ + { + accessorKey: "id", + header: ({ column }) => , + cell: ({ row }) => ( + + ID: {row.original.id} + + ), + }, + { + header: "Prediction Name", + accessorFn: (row) => getPredictionTitle(row), + cell: ({ row }) => { + const title = getPredictionTitle(row.original); + + return ( + + + {truncateString(title ?? "", 50)} + + + ); + }, + }, + { + header: "Features", + accessorFn: (row) => row.result?.count ?? 0, + cell: ({ row }) => ( + + + {formatNumber(row.original.result?.count ?? 0)} + + ), + }, + { + header: "Model", + accessorFn: (row) => getModelUsed(row), + cell: ({ row }) => { + const modelUsed = getModelUsed(row.original); + + return ( + + {truncateString(modelUsed, 40)} + + ); + }, + }, + { + header: "MapSwipe", + accessorKey: "mapswipe_id", + cell: ({ row }) => ( + onViewMapswipe(row.original)} + /> + ), + }, + { + accessorKey: "published_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => + row.original.published_at + ? formatDate(extractDatePart(row.original.published_at)) + : "-", + }, + { + header: "Info", + cell: ({ row }) => ( + { + e.stopPropagation(); + }} + className="rounded-lg px-2 items-center flex" + > + + + } + modelUsed={getModelUsed(row.original)} + createdBy={row.original.user?.username} + // dropdownRef={dropdownRef} + /> + ), + }, +]; + +export const PublishedPredictionsListLayout = ({ + data, + isPending, + isError, + refetch, + onViewDetails, +}: PublishedPredictionsListProps) => { + const [sorting, setSorting] = useState([]); + + if (isPending) { + return ; + } + + if (isError) { + return ( +
      +

      + Error loading published predictions. +

      + +
      + ); + } + + if (data.length === 0) { + return ( +
      + +

      + No published predictions found. +

      +
      + ); + } + + return ( +
      + +
      + ); +}; diff --git a/frontend/src/features/published-predictions/hooks/use-prediction-model-meta.tsx b/frontend/src/features/published-predictions/hooks/use-prediction-model-meta.tsx deleted file mode 100644 index d9af31a12..000000000 --- a/frontend/src/features/published-predictions/hooks/use-prediction-model-meta.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useMemo } from "react"; -import { useQueries } from "@tanstack/react-query"; -import { getModelDetails } from "@/features/models/api/get-models"; -import { QUERY_KEYS } from "@/services"; -import { TOfflinePrediction } from "@/types"; - -type PredictionModelMeta = { - modelNamesById: Record; - modelOwnersById: Record; -}; - -export const usePredictionModelsMeta = ( - predictions: TOfflinePrediction[], -): PredictionModelMeta => { - const modelIds = useMemo( - () => - Array.from( - new Set( - predictions - .map((prediction) => prediction.config.model_id) - .filter(Boolean), - ), - ), - [predictions], - ); - - const modelDetailsQueries = useQueries({ - queries: modelIds.map((modelId) => ({ - queryKey: [QUERY_KEYS.MODEL_DETAILS(modelId)], - queryFn: () => getModelDetails(modelId), - enabled: !!modelId, - })), - }); - - return useMemo( - () => - modelIds.reduce( - (acc, modelId, index) => { - const modelInfo = modelDetailsQueries[index]?.data; - if (modelInfo?.name) { - acc.modelNamesById[modelId] = modelInfo.name; - } - if (modelInfo?.user?.username) { - acc.modelOwnersById[modelId] = modelInfo.user.username; - } - return acc; - }, - { - modelNamesById: {}, - modelOwnersById: {}, - } as PredictionModelMeta, - ), - [modelIds, modelDetailsQueries], - ); -}; diff --git a/frontend/src/features/published-predictions/hooks/use-published-predictions.tsx b/frontend/src/features/published-predictions/hooks/use-published-predictions.tsx index b0c24c885..0ed60ad7b 100644 --- a/frontend/src/features/published-predictions/hooks/use-published-predictions.tsx +++ b/frontend/src/features/published-predictions/hooks/use-published-predictions.tsx @@ -1,39 +1,77 @@ import useDebounce from "@/hooks/use-debounce"; -import { useCallback } from "react"; -import { parseAsInteger, parseAsString, useQueryStates } from "nuqs"; +import { useCallback, useEffect } from "react"; +import { + parseAsBoolean, + parseAsInteger, + parseAsString, + useQueryStates, +} from "nuqs"; import { useQuery } from "@tanstack/react-query"; -import { getPublishedPredictionsQueryOptions } from "@/features/published-predictions/api/factory"; +import { + getPublishedPredictionsMapDataQueryOptions, + getPublishedPredictionsQueryOptions, +} from "@/features/published-predictions/api/factory"; import { PAGE_LIMIT } from "@/components/shared"; import { LayoutView } from "@/enums"; +import { SEARCH_PARAMS } from "@/utils/search-params"; +import { TQueryParams } from "@/types"; const ORDERING_OPTIONS = [ - { label: "Newest First", value: "-id" }, - { label: "Oldest First", value: "id" }, - { label: "Recently Published", value: "-published_at" }, + { label: "Newest Published", value: "-published_at" }, + { label: "Oldest Published", value: "published_at" }, ] as const; const usePublishedPredictionsSearchParams = () => { return useQueryStates({ - q: parseAsString.withDefault(""), - orderBy: parseAsString.withDefault("-id"), - offset: parseAsInteger.withDefault(0), - layout: parseAsString.withDefault(LayoutView.GRID), + [SEARCH_PARAMS.searchQuery]: parseAsString.withDefault(""), + [SEARCH_PARAMS.ordering]: parseAsString.withDefault("-id"), + [SEARCH_PARAMS.offset]: parseAsInteger.withDefault(0), + [SEARCH_PARAMS.layout]: parseAsString.withDefault(LayoutView.GRID), + [SEARCH_PARAMS.mapIsActive]: parseAsBoolean.withDefault(false), + [SEARCH_PARAMS.id]: parseAsString.withDefault(""), }); }; export const usePublishedPredictions = () => { const [params, setParams] = usePublishedPredictionsSearchParams(); + const search = params[SEARCH_PARAMS.searchQuery] as string; + const orderingParam = params[SEARCH_PARAMS.ordering] as string; + const offset = params[SEARCH_PARAMS.offset] as number; + const layout = params[SEARCH_PARAMS.layout] as string; + const mapIsActive = params[SEARCH_PARAMS.mapIsActive] as boolean; + const predictionIdParam = params[SEARCH_PARAMS.id] as string; - const debouncedSearch = useDebounce(params.q, 300); - + const debouncedSearch = useDebounce(search, 300); + const clearAllFilters = useCallback(() => { + void setParams({ + [SEARCH_PARAMS.searchQuery]: null, + [SEARCH_PARAMS.id]: null, + [SEARCH_PARAMS.offset]: 0, + }); + }, [setParams]); const { isPending, isError, data, refetch, isPlaceholderData } = useQuery({ ...getPublishedPredictionsQueryOptions( debouncedSearch.length > 0 ? debouncedSearch : undefined, - params.orderBy, - params.offset > 0 ? params.offset : undefined, + orderingParam, + offset > 0 ? offset : undefined, + predictionIdParam.length > 0 ? parseInt(predictionIdParam) : undefined, ), }); + const setPredictionId = useCallback( + (value: string | number | null) => { + void setParams({ + [SEARCH_PARAMS.id]: value ? String(value) : null, + [SEARCH_PARAMS.offset]: 0, + }); + }, + [setParams], + ); + useEffect(() => { + if ((search !== "" || predictionIdParam !== "") && offset > 0) { + void setParams({ [SEARCH_PARAMS.offset]: 0 }); + } + }, [offset, predictionIdParam, search, setParams]); const setSearch = useCallback( (value: string) => { void setParams({ q: value, offset: 0 }); @@ -54,31 +92,67 @@ export const usePublishedPredictions = () => { }, [setParams], ); - + const setMapView = useCallback( + (value: boolean) => { + void setParams({ map: value }); + }, + [setParams], + ); const goToNextPage = useCallback(() => { if (data?.hasNext) { - void setParams({ offset: params.offset + PAGE_LIMIT }); + void setParams({ offset: offset + PAGE_LIMIT }); } - }, [data?.hasNext, params.offset, setParams]); + }, [data?.hasNext, offset, setParams]); const goToPrevPage = useCallback(() => { if (data?.hasPrev) { - void setParams({ offset: Math.max(params.offset - PAGE_LIMIT, 0) }); + void setParams({ offset: Math.max(offset - PAGE_LIMIT, 0) }); } - }, [data?.hasPrev, params.offset, setParams]); + }, [data?.hasPrev, offset, setParams]); + const { + data: mapData, + isPending: isMapDataPending, + isError: isMapDataError, + } = useQuery({ + ...getPublishedPredictionsMapDataQueryOptions(), + enabled: mapIsActive && params.layout !== LayoutView.LIST, + }); + useEffect(() => { + if (params.layout === LayoutView.LIST && params.map) { + void setParams({ map: false }); + } + }, [params.layout, params.map, setParams]); + + + const query: TQueryParams = { + [SEARCH_PARAMS.searchQuery]: search, + [SEARCH_PARAMS.id]: predictionIdParam, + [SEARCH_PARAMS.offset]: offset, + [SEARCH_PARAMS.ordering]: orderingParam, + [SEARCH_PARAMS.layout]: layout, + [SEARCH_PARAMS.mapIsActive]: mapIsActive, + }; return { data, + query, + mapData, + isMapDataPending, + isMapDataError, isPending, isError, isPlaceholderData, refetch, - search: params.q, - ordering: params.orderBy, - layout: params.layout, - offset: params.offset, + search, + ordering: orderingParam, + layout, + offset, + clearAllFilters, + mapViewIsActive: mapIsActive && params.layout !== LayoutView.LIST, + setMapView, setSearch, setOrdering, + setPredictionId, setLayout, goToNextPage, goToPrevPage, diff --git a/frontend/src/services/api-routes.ts b/frontend/src/services/api-routes.ts index 12c10c821..3bc942591 100644 --- a/frontend/src/services/api-routes.ts +++ b/frontend/src/services/api-routes.ts @@ -27,6 +27,7 @@ export const API_ENDPOINTS = { GET_OFFLINE_PREDICTIONS: "prediction/", UPDATE_OFFLINE_PREDICTION: (id: number) => `prediction/${id}/`, GET_PUBLISHED_PREDICTIONS: "prediction/", + GET_PUBLISHED_PREDICTIONS_CENTROIDS: "predictions/centroid/", // Feedbacks diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index e028484f0..25bd56951 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -269,7 +269,11 @@ export type TOfflinePrediction = { status: ModelTrainingStatus; task_id: string; mapswipe_id: string | null; - user: number; + user: { + username: string; + osm_id: string; + }; + model_name: string; config: TModelPredictionsConfig; result_count: number; published: boolean; From d4d6531cf3799a8605cc8cd9afd492f2d3b40542 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Sun, 22 Mar 2026 20:48:45 +0100 Subject: [PATCH 11/62] feat: updated public predictions page --- .../published-predictions/api/factory.ts | 8 +++--- .../published-prediction-details-info.tsx | 2 +- .../components/published-predictions-grid.tsx | 27 ++++++++++++------- .../hooks/use-published-predictions.tsx | 3 +-- frontend/src/types/api.ts | 2 +- 5 files changed, 25 insertions(+), 17 deletions(-) diff --git a/frontend/src/features/published-predictions/api/factory.ts b/frontend/src/features/published-predictions/api/factory.ts index aa12373c3..ac97e96b0 100644 --- a/frontend/src/features/published-predictions/api/factory.ts +++ b/frontend/src/features/published-predictions/api/factory.ts @@ -1,12 +1,14 @@ import { queryOptions } from "@tanstack/react-query"; -import { getPublishedPredictions, getPublishedPredictionsMapData } from "@/features/published-predictions/api/get-published-predictions"; +import { + getPublishedPredictions, + getPublishedPredictionsMapData, +} from "@/features/published-predictions/api/get-published-predictions"; export const getPublishedPredictionsQueryOptions = ( searchQuery?: string, ordering?: string, offset?: number, id?: number, - ) => { return queryOptions({ queryKey: ["published-predictions", searchQuery, ordering, offset, id], @@ -19,4 +21,4 @@ export const getPublishedPredictionsMapDataQueryOptions = () => { queryKey: ["published-predictions-centroid"], queryFn: getPublishedPredictionsMapData, }); -}; \ No newline at end of file +}; diff --git a/frontend/src/features/published-predictions/components/published-prediction-details-info.tsx b/frontend/src/features/published-predictions/components/published-prediction-details-info.tsx index 0c7d72e83..e8d134560 100644 --- a/frontend/src/features/published-predictions/components/published-prediction-details-info.tsx +++ b/frontend/src/features/published-predictions/components/published-prediction-details-info.tsx @@ -18,7 +18,7 @@ export const PublishedPredictionDetailsInfo = ({ createdBy: string; dropdownRef?: MutableRefObject; placement?: DropdownPlacement; - triggerComponent?: ReactNode + triggerComponent?: ReactNode; }) => { const featureCount = prediction.result?.count ?? 0; diff --git a/frontend/src/features/published-predictions/components/published-predictions-grid.tsx b/frontend/src/features/published-predictions/components/published-predictions-grid.tsx index 43578da30..f3df07c13 100644 --- a/frontend/src/features/published-predictions/components/published-predictions-grid.tsx +++ b/frontend/src/features/published-predictions/components/published-predictions-grid.tsx @@ -13,8 +13,14 @@ type PublishedPredictionsGridProps = { isMapView?: boolean; }; -const GridSkeleton = ({isMapview }: {isMapview?: boolean}) => ( -
      +const GridSkeleton = ({ isMapview }: { isMapview?: boolean }) => ( +
      {Array.from({ length: 12 }).map((_, index) => (
      { if (isPending) { return ; @@ -62,16 +68,17 @@ export const PublishedPredictionsGrid = ({ } return ( -
      +
      {data.map((prediction) => ( diff --git a/frontend/src/features/published-predictions/hooks/use-published-predictions.tsx b/frontend/src/features/published-predictions/hooks/use-published-predictions.tsx index 0ed60ad7b..2b3ea69f5 100644 --- a/frontend/src/features/published-predictions/hooks/use-published-predictions.tsx +++ b/frontend/src/features/published-predictions/hooks/use-published-predictions.tsx @@ -124,8 +124,7 @@ export const usePublishedPredictions = () => { } }, [params.layout, params.map, setParams]); - - const query: TQueryParams = { + const query: TQueryParams = { [SEARCH_PARAMS.searchQuery]: search, [SEARCH_PARAMS.id]: predictionIdParam, [SEARCH_PARAMS.offset]: offset, diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 25bd56951..0c984adfd 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -270,7 +270,7 @@ export type TOfflinePrediction = { task_id: string; mapswipe_id: string | null; user: { - username: string; + username: string; osm_id: string; }; model_name: string; From c171ca9399579848b3e7c094b3b333db75b3eacc Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 24 Mar 2026 14:57:51 +0100 Subject: [PATCH 12/62] fix: push requested pr changes --- .../src/app/routes/published-predictions.tsx | 2 +- .../published-predictions-filters.tsx | 96 ++++++++++--------- .../published-predictions-table.tsx | 13 ++- 3 files changed, 59 insertions(+), 52 deletions(-) diff --git a/frontend/src/app/routes/published-predictions.tsx b/frontend/src/app/routes/published-predictions.tsx index 0adfd562a..ab8e7885d 100644 --- a/frontend/src/app/routes/published-predictions.tsx +++ b/frontend/src/app/routes/published-predictions.tsx @@ -52,7 +52,7 @@ export const PublishedPredictionsPage = () => { closeDialog: closePredictionResultDialog, } = useDialog(); - const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false); + const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false); const mapViewElementId = "published-predictions-map-view"; const { scrollToElement } = useScrollToElement(mapViewElementId); const { scrollToTop } = useScrollToTop(); diff --git a/frontend/src/features/published-predictions/components/published-predictions-filters.tsx b/frontend/src/features/published-predictions/components/published-predictions-filters.tsx index b36c1815b..90a986ff5 100644 --- a/frontend/src/features/published-predictions/components/published-predictions-filters.tsx +++ b/frontend/src/features/published-predictions/components/published-predictions-filters.tsx @@ -85,32 +85,64 @@ export const PublishedPredictionsFilters = ({
      {/* Count, sort, pagination, layout row */} -
      +

      {totalCount} Prediction{totalCount !== 1 ? "s" : ""}

      -
      - {/* Sort */} - { - const opt = ORDERING_OPTIONS.find( - (o) => o.label === selectedLabel, - ); - if (opt) onOrderingChange(opt.value); - }} - withCheckbox - defaultSelectedItem={selectedOrderingLabel} - triggerComponent={ -

      - Sort by -

      - } - /> +
      +
      + {/* Sort */} + { + const opt = ORDERING_OPTIONS.find( + (o) => o.label === selectedLabel, + ); + if (opt) onOrderingChange(opt.value); + }} + withCheckbox + defaultSelectedItem={selectedOrderingLabel} + triggerComponent={ +

      + Sort by +

      + } + /> + +
      + { + onMapViewChange(Boolean(params[SEARCH_PARAMS.mapIsActive])); + }} + /> + + {/* Layout toggle */} + + + +
      +
      {/* Pagination */} -
      +

      {totalCount > 0 ? offset + 1 : 0}-{endIndex} @@ -138,30 +170,6 @@ export const PublishedPredictionsFilters = ({ />

      - { - onMapViewChange(Boolean(params[SEARCH_PARAMS.mapIsActive])); - }} - /> - {/* Layout toggle */} - - -
      diff --git a/frontend/src/features/published-predictions/components/published-predictions-table.tsx b/frontend/src/features/published-predictions/components/published-predictions-table.tsx index 423d27ebe..88df6300f 100644 --- a/frontend/src/features/published-predictions/components/published-predictions-table.tsx +++ b/frontend/src/features/published-predictions/components/published-predictions-table.tsx @@ -1,7 +1,7 @@ import { Button } from "@/components/ui/button"; import { DataTable } from "@/components/ui/data-table"; import { ToolTip } from "@/components/ui/tooltip"; -import { NoTrainingAreaIcon, MapIcon, InfoIcon } from "@/components/ui/icons"; +import { NoTrainingAreaIcon,InfoIcon } from "@/components/ui/icons"; import { ColumnDef, SortingState } from "@tanstack/react-table"; import { SortableHeader } from "@/features/models/components/table-header"; import { TableSkeleton } from "@/features/models/components/skeletons"; @@ -40,12 +40,12 @@ const columnDefinitions = ( accessorKey: "id", header: ({ column }) => , cell: ({ row }) => ( - - ID: {row.original.id} - + {row.original.id} +
      ), }, { @@ -68,7 +68,6 @@ const columnDefinitions = ( accessorFn: (row) => row.result?.count ?? 0, cell: ({ row }) => ( - {formatNumber(row.original.result?.count ?? 0)} ), From 49ff6aeac9b1db17d593c358a515f72ddb41aa9c Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 24 Mar 2026 16:29:55 +0100 Subject: [PATCH 13/62] fix: push requested changes --- .../components/published-predictions-filters.tsx | 2 +- .../components/published-predictions-table.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/published-predictions/components/published-predictions-filters.tsx b/frontend/src/features/published-predictions/components/published-predictions-filters.tsx index 90a986ff5..4c782f6ed 100644 --- a/frontend/src/features/published-predictions/components/published-predictions-filters.tsx +++ b/frontend/src/features/published-predictions/components/published-predictions-filters.tsx @@ -85,7 +85,7 @@ export const PublishedPredictionsFilters = ({
      {/* Count, sort, pagination, layout row */} -
      +

      {totalCount} Prediction{totalCount !== 1 ? "s" : ""}

      diff --git a/frontend/src/features/published-predictions/components/published-predictions-table.tsx b/frontend/src/features/published-predictions/components/published-predictions-table.tsx index 88df6300f..7f18aa30c 100644 --- a/frontend/src/features/published-predictions/components/published-predictions-table.tsx +++ b/frontend/src/features/published-predictions/components/published-predictions-table.tsx @@ -1,7 +1,7 @@ import { Button } from "@/components/ui/button"; import { DataTable } from "@/components/ui/data-table"; import { ToolTip } from "@/components/ui/tooltip"; -import { NoTrainingAreaIcon,InfoIcon } from "@/components/ui/icons"; +import { NoTrainingAreaIcon, InfoIcon } from "@/components/ui/icons"; import { ColumnDef, SortingState } from "@tanstack/react-table"; import { SortableHeader } from "@/features/models/components/table-header"; import { TableSkeleton } from "@/features/models/components/skeletons"; From 8f909b5c35b04a0f36c8ef29f38f2833318fb2ef Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Fri, 27 Mar 2026 01:57:52 +0100 Subject: [PATCH 14/62] chore: renamed dataset navbar --- .../src/components/layouts/navbar/navbar.tsx | 1 + frontend/src/constants/general.ts | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/layouts/navbar/navbar.tsx b/frontend/src/components/layouts/navbar/navbar.tsx index 53f0ebd7c..b07ab8b48 100644 --- a/frontend/src/components/layouts/navbar/navbar.tsx +++ b/frontend/src/components/layouts/navbar/navbar.tsx @@ -126,6 +126,7 @@ const NavBarLinks: React.FC = ({ className, setOpen }) => {
        {navLinks .filter((link) => link.href !== "") + .filter((link) => link.active) .map((link, id) => { const isActive = location.pathname.includes(link.href) || diff --git a/frontend/src/constants/general.ts b/frontend/src/constants/general.ts index ff2ace066..c319f8d82 100644 --- a/frontend/src/constants/general.ts +++ b/frontend/src/constants/general.ts @@ -14,7 +14,7 @@ export const navLinks: TNavBarLinks = [ active: true, children: [ { - title: "Datasets", + title: "Training Datasets", href: APPLICATION_ROUTES.DATASETS, }, { @@ -53,18 +53,18 @@ export const footerLinks: TFooterGroupLinks = { active: true, }, { - title: "datasets", + title: "Training datasets", route: APPLICATION_ROUTES.DATASETS, active: true, }, - { - title: "learn", - route: APPLICATION_ROUTES.LEARN_BASE, + { + title: "AI Predictions", + route: APPLICATION_ROUTES.DATASETS, active: true, }, { - title: "about", - route: APPLICATION_ROUTES.ABOUT, + title: "learn", + route: APPLICATION_ROUTES.LEARN_BASE, active: true, }, ], @@ -80,6 +80,11 @@ export const footerLinks: TFooterGroupLinks = { route: HOT_PRIVACY_POLICY_URL, isExternalLink: true, active: true, + }, + { + title: "about", + route: APPLICATION_ROUTES.ABOUT, + active: true, }, ], }; From 93df2f4b05d692a7add84b8a83dd01a8790f18e6 Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Fri, 27 Mar 2026 02:08:16 +0100 Subject: [PATCH 15/62] chore: fixed focus style bug in dropdown menu --- .../layouts/navbar/navbar.module.css | 2 ++ .../src/components/ui/dropdown/dropdown.css | 19 +++++++++++++++---- frontend/src/constants/general.ts | 4 ++-- .../published-predictions-filters.tsx | 2 +- .../published-predictions-table.tsx | 2 +- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/layouts/navbar/navbar.module.css b/frontend/src/components/layouts/navbar/navbar.module.css index 825f31c92..4de13d4cb 100644 --- a/frontend/src/components/layouts/navbar/navbar.module.css +++ b/frontend/src/components/layouts/navbar/navbar.module.css @@ -15,6 +15,8 @@ nav { display: none; } + + .mobileNavLinks { display: flex; flex-direction: column; diff --git a/frontend/src/components/ui/dropdown/dropdown.css b/frontend/src/components/ui/dropdown/dropdown.css index 650f6bc2e..2332c392c 100644 --- a/frontend/src/components/ui/dropdown/dropdown.css +++ b/frontend/src/components/ui/dropdown/dropdown.css @@ -1,7 +1,3 @@ -/* - Customizing the menu item for the logout button in the user profile dropdown. - The 'logoutButton' class is passed from the userprofile component. -*/ sl-menu-item::part(base) { color: var(--hot-fair-color-dark); @@ -13,6 +9,21 @@ sl-menu-item::part(base):hover { background-color: var(--hot-fair-color-hover-accent); } +/* Remove blue focus line */ +sl-menu-item::part(base):focus, +sl-menu-item::part(base):active, +sl-menu-item::part(base):focus-visible{ + outline: none; + box-shadow: none; + border: none; + +} + +/* + Customizing the menu item for the logout button in the user profile dropdown. + The 'logoutButton' class is passed from the userprofile component. +*/ + sl-menu-item.logoutButton::part(base) { color: var(--hot-fair-color-primary); } diff --git a/frontend/src/constants/general.ts b/frontend/src/constants/general.ts index c319f8d82..869bd2bea 100644 --- a/frontend/src/constants/general.ts +++ b/frontend/src/constants/general.ts @@ -57,7 +57,7 @@ export const footerLinks: TFooterGroupLinks = { route: APPLICATION_ROUTES.DATASETS, active: true, }, - { + { title: "AI Predictions", route: APPLICATION_ROUTES.DATASETS, active: true, @@ -81,7 +81,7 @@ export const footerLinks: TFooterGroupLinks = { isExternalLink: true, active: true, }, - { + { title: "about", route: APPLICATION_ROUTES.ABOUT, active: true, diff --git a/frontend/src/features/published-predictions/components/published-predictions-filters.tsx b/frontend/src/features/published-predictions/components/published-predictions-filters.tsx index 90a986ff5..4c782f6ed 100644 --- a/frontend/src/features/published-predictions/components/published-predictions-filters.tsx +++ b/frontend/src/features/published-predictions/components/published-predictions-filters.tsx @@ -85,7 +85,7 @@ export const PublishedPredictionsFilters = ({
      {/* Count, sort, pagination, layout row */} -
      +

      {totalCount} Prediction{totalCount !== 1 ? "s" : ""}

      diff --git a/frontend/src/features/published-predictions/components/published-predictions-table.tsx b/frontend/src/features/published-predictions/components/published-predictions-table.tsx index 88df6300f..7f18aa30c 100644 --- a/frontend/src/features/published-predictions/components/published-predictions-table.tsx +++ b/frontend/src/features/published-predictions/components/published-predictions-table.tsx @@ -1,7 +1,7 @@ import { Button } from "@/components/ui/button"; import { DataTable } from "@/components/ui/data-table"; import { ToolTip } from "@/components/ui/tooltip"; -import { NoTrainingAreaIcon,InfoIcon } from "@/components/ui/icons"; +import { NoTrainingAreaIcon, InfoIcon } from "@/components/ui/icons"; import { ColumnDef, SortingState } from "@tanstack/react-table"; import { SortableHeader } from "@/features/models/components/table-header"; import { TableSkeleton } from "@/features/models/components/skeletons"; From 746a8bb6c600136fc6f2a9197ee322172032bb85 Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Fri, 27 Mar 2026 02:24:28 +0100 Subject: [PATCH 16/62] chore: capitalized navbar/login button --- frontend/src/components/layouts/navbar/navbar.module.css | 2 -- frontend/src/components/layouts/navbar/navbar.tsx | 7 ++++--- frontend/src/components/ui/dropdown/dropdown.css | 4 +--- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/layouts/navbar/navbar.module.css b/frontend/src/components/layouts/navbar/navbar.module.css index 4de13d4cb..825f31c92 100644 --- a/frontend/src/components/layouts/navbar/navbar.module.css +++ b/frontend/src/components/layouts/navbar/navbar.module.css @@ -15,8 +15,6 @@ nav { display: none; } - - .mobileNavLinks { display: flex; flex-direction: column; diff --git a/frontend/src/components/layouts/navbar/navbar.tsx b/frontend/src/components/layouts/navbar/navbar.tsx index b07ab8b48..5d7c4f245 100644 --- a/frontend/src/components/layouts/navbar/navbar.tsx +++ b/frontend/src/components/layouts/navbar/navbar.tsx @@ -78,6 +78,7 @@ export const NavBar = () => { ) : ( @@ -62,7 +62,7 @@ export const OfflinePredictionCard = ({ variant={ButtonVariant.DARK} className="!w-fit" size={SHOELACE_SIZES.SMALL} - uppercase={false} + >

      Zoom: {predictionResult.config.zoom_level}

      From 394a7de5c71fa859e29c364561f72ca1a4e9a613 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Wed, 1 Apr 2026 15:16:03 +0100 Subject: [PATCH 19/62] fix: fixed mapswipe result downloading instead of showing modal --- .../src/app/routes/published-predictions.tsx | 72 +++++++++++++------ 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/frontend/src/app/routes/published-predictions.tsx b/frontend/src/app/routes/published-predictions.tsx index ab8e7885d..6f5f1386b 100644 --- a/frontend/src/app/routes/published-predictions.tsx +++ b/frontend/src/app/routes/published-predictions.tsx @@ -6,6 +6,8 @@ import { PredictionResultDrawer } from "@/features/user-profile/components/offli import { FeatureCollection, TOfflinePrediction } from "@/types"; import { useEffect, useState } from "react"; import { useDialog } from "@/hooks/use-dialog"; +import { MapSwipeProjectResultMapDrawer } from "@/features/mapswipe/components/project-results-map"; + import PageHeader from "@/features/models/components/header"; import { MapswipeProjectStatusDialog } from "@/features/mapswipe/components/project-status-dialog"; import { @@ -42,7 +44,35 @@ export const PublishedPredictionsPage = () => { setPredictionId, clearAllFilters, } = usePublishedPredictions(); + const { + isOpened: isMapswipeDialogOpen, + openDialog: openMapSwipeProjectStatusDialog, + closeDialog: closeMapSwipeProjectStatusDialog, + } = useDialog(); + const [mapSwipeResultsPmtiles, setMapSwipeResultsPmtiles] = useState(null); + const { + isOpened: isMapSwipeProjectResultMapOpened, + openDialog: openMapSwipeProjectResultMapDialog, + closeDialog: closeMapSwipeProjectResultMapDialog, + } = useDialog(); + const handleViewMapswipe = (prediction: TOfflinePrediction) => { + setMapSwipeResultsPmtiles(null); + setActivePrediction(prediction); + openMapSwipeProjectStatusDialog(); + }; + + const handleMapSwipeProjectResultMapModal = (pmtiles: string) => { + setMapSwipeResultsPmtiles(pmtiles); + closeMapSwipeProjectStatusDialog(); + openMapSwipeProjectResultMapDialog(); + }; + + const handleCloseMapSwipeProjectResultMapModal = () => { + closeMapSwipeProjectResultMapDialog(); + setMapSwipeResultsPmtiles(null); + openMapSwipeProjectStatusDialog(); + }; const [activePrediction, setActivePrediction] = useState(null); @@ -52,7 +82,6 @@ export const PublishedPredictionsPage = () => { closeDialog: closePredictionResultDialog, } = useDialog(); - const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false); const mapViewElementId = "published-predictions-map-view"; const { scrollToElement } = useScrollToElement(mapViewElementId); const { scrollToTop } = useScrollToTop(); @@ -64,15 +93,7 @@ export const PublishedPredictionsPage = () => { openPredictionResultDialog(); }; - const handleViewDetails = (prediction: TOfflinePrediction) => { - setActivePrediction(prediction); - setIsDetailDialogOpen(true); - }; - const handleCloseDetail = () => { - setIsDetailDialogOpen(false); - setActivePrediction(null); - }; useEffect(() => { if (mapViewIsActive) { @@ -94,14 +115,14 @@ export const PublishedPredictionsPage = () => { refetch={refetch} isMapView={mapViewIsActive} onViewResults={handleViewResults} - onViewDetails={handleViewDetails} + onViewDetails={handleViewMapswipe} />
      {isMapDataPending || - isMapDataError || - !mapData || - mapData.features.length === 0 ? ( + isMapDataError || + !mapData || + mapData.features.length === 0 ? (
      @@ -125,7 +146,7 @@ export const PublishedPredictionsPage = () => { isError={isError} refetch={refetch} onViewResults={handleViewResults} - onViewDetails={handleViewDetails} + onViewDetails={handleViewMapswipe} /> ) : ( { isError={isError} refetch={refetch} onViewResults={handleViewResults} - onViewDetails={handleViewDetails} + onViewDetails={handleViewMapswipe} /> )}
      @@ -159,15 +180,24 @@ export const PublishedPredictionsPage = () => { )} {/* Detail dialog */} + {activePrediction && ( { - // Can be expanded to open a PM tiles viewer if requested - window.open(pmtiles, "_blank"); - }} + handleMapSwipeProjectResultMapModal={handleMapSwipeProjectResultMapModal} + /> + )} + + {activePrediction && mapSwipeResultsPmtiles && ( + )} From dcca184a69ce397b149dba95b4f70ac8b9991b31 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Wed, 1 Apr 2026 15:56:02 +0100 Subject: [PATCH 20/62] fix: mapswipe modal not showing up --- .../src/app/routes/published-predictions.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/routes/published-predictions.tsx b/frontend/src/app/routes/published-predictions.tsx index 6f5f1386b..255728e53 100644 --- a/frontend/src/app/routes/published-predictions.tsx +++ b/frontend/src/app/routes/published-predictions.tsx @@ -49,7 +49,9 @@ export const PublishedPredictionsPage = () => { openDialog: openMapSwipeProjectStatusDialog, closeDialog: closeMapSwipeProjectStatusDialog, } = useDialog(); - const [mapSwipeResultsPmtiles, setMapSwipeResultsPmtiles] = useState(null); + const [mapSwipeResultsPmtiles, setMapSwipeResultsPmtiles] = useState< + string | null + >(null); const { isOpened: isMapSwipeProjectResultMapOpened, @@ -93,8 +95,6 @@ export const PublishedPredictionsPage = () => { openPredictionResultDialog(); }; - - useEffect(() => { if (mapViewIsActive) { scrollToElement(); @@ -120,9 +120,9 @@ export const PublishedPredictionsPage = () => {
      {isMapDataPending || - isMapDataError || - !mapData || - mapData.features.length === 0 ? ( + isMapDataError || + !mapData || + mapData.features.length === 0 ? (
      @@ -186,7 +186,9 @@ export const PublishedPredictionsPage = () => { isOpen={isMapswipeDialogOpen} onClose={closeMapSwipeProjectStatusDialog} mapSwipeProjectId={activePrediction.mapswipe_id ?? ""} - handleMapSwipeProjectResultMapModal={handleMapSwipeProjectResultMapModal} + handleMapSwipeProjectResultMapModal={ + handleMapSwipeProjectResultMapModal + } /> )} From 3002db7b4917b6fdb68c7b764e969d4ed2b46ded Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Sun, 12 Apr 2026 14:57:55 +0200 Subject: [PATCH 21/62] chore: clean ups. renamed published predictions to ai-predictions --- frontend/src/app/router.tsx | 8 +++--- ...hed-predictions.tsx => ai-predictions.tsx} | 26 +++++++++---------- frontend/src/constants/general.ts | 4 +-- frontend/src/constants/routes.ts | 4 +-- .../features/ai-predictions/api/factory.ts | 21 +++++++++++++++ .../api/get-ai-predictions.ts} | 12 ++++----- .../components/ai-prediction-card.tsx} | 21 +++++++-------- .../ai-prediction-detail-dialog.tsx} | 0 .../ai-prediction-details-info.tsx} | 2 +- .../components/ai-predictions-filters.tsx} | 8 +++--- .../components/ai-predictions-grid.tsx} | 14 +++++----- .../components/ai-predictions-list.tsx} | 10 +++---- .../components/ai-predictions-map.tsx} | 6 ++--- .../components/ai-predictions-table.tsx} | 17 ++++++------ .../hooks/use-ai-predictions.tsx} | 16 ++++++------ .../datasets/components/dataset-card.tsx | 8 +++--- .../published-predictions/api/factory.ts | 24 ----------------- frontend/src/services/api-routes.ts | 4 +-- 18 files changed, 100 insertions(+), 105 deletions(-) rename frontend/src/app/routes/{published-predictions.tsx => ai-predictions.tsx} (86%) create mode 100644 frontend/src/features/ai-predictions/api/factory.ts rename frontend/src/features/{published-predictions/api/get-published-predictions.ts => ai-predictions/api/get-ai-predictions.ts} (70%) rename frontend/src/features/{published-predictions/components/published-prediction-card.tsx => ai-predictions/components/ai-prediction-card.tsx} (92%) rename frontend/src/features/{published-predictions/components/published-prediction-detail-dialog.tsx => ai-predictions/components/ai-prediction-detail-dialog.tsx} (100%) rename frontend/src/features/{published-predictions/components/published-prediction-details-info.tsx => ai-predictions/components/ai-prediction-details-info.tsx} (97%) rename frontend/src/features/{published-predictions/components/published-predictions-filters.tsx => ai-predictions/components/ai-predictions-filters.tsx} (96%) rename frontend/src/features/{published-predictions/components/published-predictions-grid.tsx => ai-predictions/components/ai-predictions-grid.tsx} (86%) rename frontend/src/features/{published-predictions/components/published-predictions-list.tsx => ai-predictions/components/ai-predictions-list.tsx} (94%) rename frontend/src/features/{published-predictions/components/published-predictions-map.tsx => ai-predictions/components/ai-predictions-map.tsx} (97%) rename frontend/src/features/{published-predictions/components/published-predictions-table.tsx => ai-predictions/components/ai-predictions-table.tsx} (91%) rename frontend/src/features/{published-predictions/hooks/use-published-predictions.tsx => ai-predictions/hooks/use-ai-predictions.tsx} (91%) delete mode 100644 frontend/src/features/published-predictions/api/factory.ts diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 0092b8d29..ede9f9bb8 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -102,13 +102,13 @@ const router = createBrowserRouter([ * AI Predictions route (published predictions). */ { - path: APPLICATION_ROUTES.PUBLISHED_PREDICTIONS, + path: APPLICATION_ROUTES.AI_PREDICTIONS, lazy: async () => { - const { PublishedPredictionsPage } = await import( - "@/app/routes/published-predictions" + const { AIPredictionsPage } = await import( + "@/app/routes/ai-predictions" ); return { - Component: () => , + Component: () => , }; }, }, diff --git a/frontend/src/app/routes/published-predictions.tsx b/frontend/src/app/routes/ai-predictions.tsx similarity index 86% rename from frontend/src/app/routes/published-predictions.tsx rename to frontend/src/app/routes/ai-predictions.tsx index 255728e53..cb191cf9b 100644 --- a/frontend/src/app/routes/published-predictions.tsx +++ b/frontend/src/app/routes/ai-predictions.tsx @@ -1,7 +1,7 @@ import { Head } from "@/components/seo"; -import { PublishedPredictionsFilters } from "@/features/published-predictions/components/published-predictions-filters"; -import { PublishedPredictionsGrid } from "@/features/published-predictions/components/published-predictions-grid"; -import { usePublishedPredictions } from "@/features/published-predictions/hooks/use-published-predictions"; +import { AIPredictionsFilters } from "@/features/ai-predictions/components/ai-predictions-filters"; +import { AIPredictionsGrid } from "@/features/ai-predictions/components/ai-predictions-grid"; +import { useAIPredictions } from "@/features/ai-predictions/hooks/use-ai-predictions"; import { PredictionResultDrawer } from "@/features/user-profile/components/offline-predictions/predictions-results-drawer"; import { FeatureCollection, TOfflinePrediction } from "@/types"; import { useEffect, useState } from "react"; @@ -15,11 +15,11 @@ import { useScrollToTop, } from "@/hooks/use-scroll-to-element"; import { LayoutView } from "@/enums"; -import { PublishedPredictionsListLayout } from "@/features/published-predictions/components/published-predictions-table"; +import { AIPredictionsListLayout } from "@/features/ai-predictions/components/ai-predictions-table"; import { Spinner } from "@/components/ui/spinner"; -import { PublishedPredictionsMap } from "@/features/published-predictions/components/published-predictions-map"; +import { AIPredictionsMap } from "@/features/ai-predictions/components/ai-predictions-map"; -export const PublishedPredictionsPage = () => { +export const AIPredictionsPage = () => { const { data, isPending, @@ -43,7 +43,7 @@ export const PublishedPredictionsPage = () => { goToPrevPage, setPredictionId, clearAllFilters, - } = usePublishedPredictions(); + } = useAIPredictions(); const { isOpened: isMapswipeDialogOpen, openDialog: openMapSwipeProjectStatusDialog, @@ -108,7 +108,7 @@ export const PublishedPredictionsPage = () => { return (
      - {
      ) : ( - @@ -140,7 +140,7 @@ export const PublishedPredictionsPage = () => { return (
      {isListView ? ( - { onViewDetails={handleViewMapswipe} /> ) : ( - { {/* Filters */} - { + return queryOptions({ + queryKey: ["ai-predictions", searchQuery, ordering, offset, id], + queryFn: () => getAIPredictions(searchQuery, ordering, offset, id), + }); +}; + +export const getAIPredictionsMapDataQueryOptions = () => { + return queryOptions({ + queryKey: ["ai-predictions-centroid"], + queryFn: getAIPredictionsMapData, + }); +}; diff --git a/frontend/src/features/published-predictions/api/get-published-predictions.ts b/frontend/src/features/ai-predictions/api/get-ai-predictions.ts similarity index 70% rename from frontend/src/features/published-predictions/api/get-published-predictions.ts rename to frontend/src/features/ai-predictions/api/get-ai-predictions.ts index 64bf55363..41172c73e 100644 --- a/frontend/src/features/published-predictions/api/get-published-predictions.ts +++ b/frontend/src/features/ai-predictions/api/get-ai-predictions.ts @@ -2,7 +2,7 @@ import { PAGE_LIMIT } from "@/components/shared"; import { API_ENDPOINTS, apiClient } from "@/services"; import { FeatureCollection, TOfflinePrediction } from "@/types"; -export type PublishedPredictionsResponse = { +export type AIPredictionsResponse = { count: number; next: string | null; previous: string | null; @@ -11,13 +11,13 @@ export type PublishedPredictionsResponse = { hasPrev: boolean; }; -export const getPublishedPredictions = async ( +export const getAIPredictions = async ( searchQuery?: string, ordering: string = "-id", offset?: number, id?: number, -): Promise => { - const res = await apiClient.get(API_ENDPOINTS.GET_PUBLISHED_PREDICTIONS, { +): Promise => { + const res = await apiClient.get(API_ENDPOINTS.GET_AI_PREDICTIONS, { params: { published: true, search: searchQuery, @@ -34,10 +34,10 @@ export const getPublishedPredictions = async ( }; }; -export const getPublishedPredictionsMapData = +export const getAIPredictionsMapData = async (): Promise => { const res = await apiClient.get( - API_ENDPOINTS.GET_PUBLISHED_PREDICTIONS_CENTROIDS, + API_ENDPOINTS.GET_AI_PREDICTIONS_CENTROIDS, ); return res.data; diff --git a/frontend/src/features/published-predictions/components/published-prediction-card.tsx b/frontend/src/features/ai-predictions/components/ai-prediction-card.tsx similarity index 92% rename from frontend/src/features/published-predictions/components/published-prediction-card.tsx rename to frontend/src/features/ai-predictions/components/ai-prediction-card.tsx index d6ff9d410..d169528b6 100644 --- a/frontend/src/features/published-predictions/components/published-prediction-card.tsx +++ b/frontend/src/features/ai-predictions/components/ai-prediction-card.tsx @@ -9,19 +9,19 @@ import { useDropdownMenu } from "@/hooks/use-dropdown-menu"; import { API_ENDPOINTS } from "@/services"; import { TOfflinePrediction } from "@/types"; import { extractDatePart, formatDate, showSuccessToast } from "@/utils"; -import { PublishedPredictionDetailsInfo } from "./published-prediction-details-info"; +import { AIPredictionDetailsInfo } from "./ai-prediction-details-info"; -type PublishedPredictionCardProps = { +type AIPredictionCardProps = { prediction: TOfflinePrediction; onViewResults: (prediction: TOfflinePrediction) => void; onViewDetails: (prediction: TOfflinePrediction) => void; }; -export const PublishedPredictionCard = ({ +export const AIPredictionCard = ({ prediction, onViewResults, onViewDetails, -}: PublishedPredictionCardProps) => { +}: AIPredictionCardProps) => { const { copyToClipboard } = useCopyToClipboard(); const { dropdownRef } = useDropdownMenu(); const title = prediction.description || `Prediction ${prediction.id}`; @@ -157,15 +157,15 @@ export const PublishedPredictionCard = ({ {/* Card body */}
      -
      +
      -

      Model Used:

      - {prediction.model_name} +

      Model Used:

      + {prediction.model_name}
      -

      +

      Date Published:{" "} - + {prediction.published_at ? formatDate( extractDatePart(prediction.published_at as string), @@ -177,9 +177,8 @@ export const PublishedPredictionCard = ({

      - {/* Added the */}
      - void; ordering: string; @@ -30,7 +30,7 @@ type PublishedPredictionsFiltersProps = { isPlaceholderData: boolean; }; -export const PublishedPredictionsFilters = ({ +export const AIPredictionsFilters = ({ search, onSearchChange, ordering, @@ -48,7 +48,7 @@ export const PublishedPredictionsFilters = ({ onMapViewChange, mapViewIsActive, query, -}: PublishedPredictionsFiltersProps) => { +}: AIPredictionsFiltersProps) => { const orderingMenuItems = ORDERING_OPTIONS.map((opt) => ({ value: opt.label, apiValue: opt.value, diff --git a/frontend/src/features/published-predictions/components/published-predictions-grid.tsx b/frontend/src/features/ai-predictions/components/ai-predictions-grid.tsx similarity index 86% rename from frontend/src/features/published-predictions/components/published-predictions-grid.tsx rename to frontend/src/features/ai-predictions/components/ai-predictions-grid.tsx index f3df07c13..7e8c394c4 100644 --- a/frontend/src/features/published-predictions/components/published-predictions-grid.tsx +++ b/frontend/src/features/ai-predictions/components/ai-predictions-grid.tsx @@ -1,9 +1,9 @@ import { Button } from "@/components/ui/button"; import { NoTrainingAreaIcon } from "@/components/ui/icons"; import { TOfflinePrediction } from "@/types"; -import { PublishedPredictionCard } from "./published-prediction-card"; +import { AIPredictionCard } from "./ai-prediction-card"; -type PublishedPredictionsGridProps = { +type AIPredictionsGridProps = { data: TOfflinePrediction[]; isPending: boolean; isError: boolean; @@ -30,7 +30,7 @@ const GridSkeleton = ({ isMapview }: { isMapview?: boolean }) => (
      ); -export const PublishedPredictionsGrid = ({ +export const AIPredictionsGrid = ({ data, isPending, isError, @@ -38,7 +38,7 @@ export const PublishedPredictionsGrid = ({ onViewResults, onViewDetails, isMapView, -}: PublishedPredictionsGridProps) => { +}: AIPredictionsGridProps) => { if (isPending) { return ; } @@ -47,7 +47,7 @@ export const PublishedPredictionsGrid = ({ return (

      - Error loading published predictions. + Error loading AI predictions.

      ); -export const PublishedPredictionsList = ({ +export const AIPredictionsList = ({ data, isPending, isError, refetch, onViewResults, onViewDetails, -}: PublishedPredictionsListProps) => { +}: AIPredictionsListProps) => { if (isPending) { return ; } @@ -39,7 +39,7 @@ export const PublishedPredictionsList = ({ return (

      - Error loading published predictions. + Error loading AI predictions.

      -

      Used by:

      -

      +

      Used by:

      +

      {dataset.models_count} Model{dataset.models_count ? "s" : ""}

      {showUsername && (
      -

      +

      Created by:

      -

      +

      {dataset.user.username}

      diff --git a/frontend/src/features/published-predictions/api/factory.ts b/frontend/src/features/published-predictions/api/factory.ts deleted file mode 100644 index ac97e96b0..000000000 --- a/frontend/src/features/published-predictions/api/factory.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { queryOptions } from "@tanstack/react-query"; -import { - getPublishedPredictions, - getPublishedPredictionsMapData, -} from "@/features/published-predictions/api/get-published-predictions"; - -export const getPublishedPredictionsQueryOptions = ( - searchQuery?: string, - ordering?: string, - offset?: number, - id?: number, -) => { - return queryOptions({ - queryKey: ["published-predictions", searchQuery, ordering, offset, id], - queryFn: () => getPublishedPredictions(searchQuery, ordering, offset, id), - }); -}; - -export const getPublishedPredictionsMapDataQueryOptions = () => { - return queryOptions({ - queryKey: ["published-predictions-centroid"], - queryFn: getPublishedPredictionsMapData, - }); -}; diff --git a/frontend/src/services/api-routes.ts b/frontend/src/services/api-routes.ts index 3bc942591..92e347b40 100644 --- a/frontend/src/services/api-routes.ts +++ b/frontend/src/services/api-routes.ts @@ -26,8 +26,8 @@ export const API_ENDPOINTS = { CREATE_OFFLINE_PREDICTION: "prediction/", GET_OFFLINE_PREDICTIONS: "prediction/", UPDATE_OFFLINE_PREDICTION: (id: number) => `prediction/${id}/`, - GET_PUBLISHED_PREDICTIONS: "prediction/", - GET_PUBLISHED_PREDICTIONS_CENTROIDS: "predictions/centroid/", + GET_AI_PREDICTIONS: "prediction/", + GET_AI_PREDICTIONS_CENTROIDS: "predictions/centroid/", // Feedbacks From 07ac7f2633f03e5b58d74c5000f792d993fdd0f5 Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Sun, 12 Apr 2026 15:25:55 +0200 Subject: [PATCH 22/62] feat: added model_name to prediction request config| --- frontend/src/components/auth/auth-modal.tsx | 6 +----- frontend/src/components/layouts/navbar/navbar.tsx | 2 -- frontend/src/components/ui/button/button.css | 2 -- frontend/src/components/ui/button/icon-button.tsx | 1 - frontend/src/components/ui/link/link.module.css | 2 +- frontend/src/features/ai-predictions/api/factory.ts | 5 ++++- .../features/ai-predictions/api/get-ai-predictions.ts | 11 ++++------- .../ai-predictions/components/ai-prediction-card.tsx | 4 +++- .../components/ai-prediction-detail-dialog.tsx | 1 - .../ai-predictions/components/ai-predictions-grid.tsx | 4 +--- .../ai-predictions/components/ai-predictions-list.tsx | 4 +--- .../ai-predictions/components/ai-predictions-map.tsx | 7 ++++--- .../components/ai-predictions-table.tsx | 10 ++-------- .../src/features/datasets/components/dataset-card.tsx | 4 +--- .../mapswipe/components/project-status-dialog.tsx | 1 - .../dialogs/offline-prediction-request-dialog.tsx | 11 ++++++++++- .../start-mapping/components/header/header.tsx | 4 +++- .../start-mapping/components/header/model-action.tsx | 6 +++++- .../replicable-models/imagery-source-selector.tsx | 2 -- .../components/replicable-models/model-selector.tsx | 1 - .../components/notifications/notifications-panel.tsx | 2 -- .../offline-predictions/offline-prediction-card.tsx | 2 -- frontend/src/types/api.ts | 1 + 23 files changed, 41 insertions(+), 52 deletions(-) diff --git a/frontend/src/components/auth/auth-modal.tsx b/frontend/src/components/auth/auth-modal.tsx index 0ad8caae4..2ba1fb3ce 100644 --- a/frontend/src/components/auth/auth-modal.tsx +++ b/frontend/src/components/auth/auth-modal.tsx @@ -47,11 +47,7 @@ export const AuthenticationModal = ({ {callbackPage || emailVerification ? ( ) : ( - @@ -62,7 +61,6 @@ export const OfflinePredictionCard = ({ variant={ButtonVariant.DARK} className="!w-fit" size={SHOELACE_SIZES.SMALL} - >

      Zoom: {predictionResult.config.zoom_level}

      diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 0c984adfd..bc216f338 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -234,6 +234,7 @@ export type TPredictionsConfig = { zoom_level: number; source_imagery?: string; folder?: string; + model_name?: string; }; export type TModelPredictionsConfig = TPredictionsConfig & { From 3740164e4d6f43299bec267e0becb484875597c1 Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Sun, 12 Apr 2026 15:45:20 +0200 Subject: [PATCH 23/62] chore: fixed type mismatch --- frontend/src/app/routes/profile/settings.tsx | 2 -- .../components/dialogs/offline-prediction-request-dialog.tsx | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/app/routes/profile/settings.tsx b/frontend/src/app/routes/profile/settings.tsx index 669a3c428..70f126c12 100644 --- a/frontend/src/app/routes/profile/settings.tsx +++ b/frontend/src/app/routes/profile/settings.tsx @@ -395,7 +395,6 @@ export const UserProfileSettingsPage = () => { } variant={ButtonVariant.PRIMARY} prefixIcon={DeleteIcon} - uppercase={false} className="!w-fit" textClassName="p-0.5 md:px-1 md:py-2 text-body-4" onClick={openDialog} @@ -418,7 +417,6 @@ export const UserProfileSettingsPage = () => { Date: Sun, 12 Apr 2026 15:57:26 +0200 Subject: [PATCH 24/62] chore: fallback model_name to model_name in config object --- .../features/ai-predictions/components/ai-prediction-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/features/ai-predictions/components/ai-prediction-card.tsx b/frontend/src/features/ai-predictions/components/ai-prediction-card.tsx index df60b0439..46c23ed6a 100644 --- a/frontend/src/features/ai-predictions/components/ai-prediction-card.tsx +++ b/frontend/src/features/ai-predictions/components/ai-prediction-card.tsx @@ -161,7 +161,7 @@ export const AIPredictionCard = ({

      Model Used:

      - {prediction.model_name} + {prediction.model_name ?? prediction.config.model_name}
      From 08356307ec77b63b6a496e49005b4fb62e8daf5a Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Fri, 20 Mar 2026 04:38:35 +0000 Subject: [PATCH 25/62] build: add a simple helm chart for backend deployment only + publish pipeline --- .github/workflows/release_chart.yaml | 24 +++ .gitignore | 3 + Justfile | 30 ++++ chart/.helmignore | 9 ++ chart/Chart.yaml | 6 + chart/README.md | 48 ++++++ chart/templates/NOTES.txt | 20 +++ chart/templates/_helpers.tpl | 90 +++++++++++ chart/templates/backend/configmap.yaml | 15 ++ chart/templates/backend/deployment.yaml | 126 +++++++++++++++ chart/templates/backend/migrate-job.yaml | 53 ++++++ chart/templates/backend/secret.yaml | 12 ++ chart/templates/backend/service.yaml | 16 ++ chart/templates/ingress.yaml | 41 +++++ chart/templates/serviceaccount.yaml | 12 ++ chart/values.yaml | 116 ++++++++++++++ tasks/chart | 195 +++++++++++++++++++++++ 17 files changed, 816 insertions(+) create mode 100755 .github/workflows/release_chart.yaml create mode 100644 Justfile create mode 100644 chart/.helmignore create mode 100644 chart/Chart.yaml create mode 100644 chart/README.md create mode 100644 chart/templates/NOTES.txt create mode 100644 chart/templates/_helpers.tpl create mode 100644 chart/templates/backend/configmap.yaml create mode 100644 chart/templates/backend/deployment.yaml create mode 100644 chart/templates/backend/migrate-job.yaml create mode 100644 chart/templates/backend/secret.yaml create mode 100644 chart/templates/backend/service.yaml create mode 100644 chart/templates/ingress.yaml create mode 100644 chart/templates/serviceaccount.yaml create mode 100644 chart/values.yaml create mode 100644 tasks/chart diff --git a/.github/workflows/release_chart.yaml b/.github/workflows/release_chart.yaml new file mode 100755 index 000000000..3222e1b39 --- /dev/null +++ b/.github/workflows/release_chart.yaml @@ -0,0 +1,24 @@ +name: Release Chart + +on: + # Push includes PR merge + push: + branches: + - develop + paths: + # Workflow is triggered only if chart dir changes + - chart/** + # Allow manual trigger + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + publish: + uses: hotosm/gh-workflows/.github/workflows/just.yml@3.3.2 + with: + environment: "test" + command: "chart publish" + secrets: inherit diff --git a/.gitignore b/.gitignore index 5df042fa9..7b88b02bd 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ fAIr-utilities data fair-app-data/* + +# helm charts +fair-*.tgz diff --git a/Justfile b/Justfile new file mode 100644 index 000000000..84d55da4c --- /dev/null +++ b/Justfile @@ -0,0 +1,30 @@ +set dotenv-load + +mod chart 'tasks/chart' + +# List available commands +[private] +default: + just help + +# List available commands +help: + just --justfile {{justfile()}} --list + +# Echo to terminal with blue colour +[no-cd] +_echo-blue text: + #!/usr/bin/env sh + printf "\033[0;34m%s\033[0m\n" "{{ text }}" + +# Echo to terminal with yellow colour +[no-cd] +_echo-yellow text: + #!/usr/bin/env sh + printf "\033[0;33m%s\033[0m\n" "{{ text }}" + +# Echo to terminal with red colour +[no-cd] +_echo-red text: + #!/usr/bin/env sh + printf "\033[0;41m%s\033[0m\n" "{{ text }}" diff --git a/chart/.helmignore b/chart/.helmignore new file mode 100644 index 000000000..acf3c7144 --- /dev/null +++ b/chart/.helmignore @@ -0,0 +1,9 @@ +.DS_Store +.git +.gitignore +.idea +*.swp +*.bak +*.tmp +*.orig +*~ diff --git a/chart/Chart.yaml b/chart/Chart.yaml new file mode 100644 index 000000000..5b3221e73 --- /dev/null +++ b/chart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: fair +description: AI Assisted Mapping Tool +type: application +version: 0.1.0 +appVersion: "2.2.19" diff --git a/chart/README.md b/chart/README.md new file mode 100644 index 000000000..d1c2d4033 --- /dev/null +++ b/chart/README.md @@ -0,0 +1,48 @@ +# fAIr Helm Chart + +Deploys the fAIr backend API and Django-Q async worker. + +The frontend is deployed separately (S3 + CloudFront via GitHub Actions). +PostgreSQL is expected to be provided externally (e.g. +[CloudNativePG](https://cloudnative-pg.io/) or a managed database service). + +## Quick start + +```bash +helm install fair oci://ghcr.io/hotosm/charts/fair +``` + +## Example values + +```yaml +externalDatabase: + host: my-pg-cluster-rw.db.svc + database: ai + username: fair + existingSecret: fair-db-credentials + existingSecretKey: password + +ingress: + enabled: true + className: nginx + hosts: + - host: fair.example.com + paths: + - path: /api + pathType: Prefix + +backend: + envFrom: + - secretRef: + name: fair-backend-secrets +``` + +## Key values + +| Parameter | Description | Default | +|---|---|---| +| `externalDatabase.host` | PostgreSQL host | `""` | +| `externalDatabase.existingSecret` | Secret containing DB password | `""` | +| `backend.djangoQ.enabled` | Run Django-Q sidecar for async tasks | `true` | +| `backend.migrate.enabled` | Run migrations on install/upgrade | `true` | +| `ingress.enabled` | Create Ingress resource | `false` | diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt new file mode 100644 index 000000000..bfc7cfcda --- /dev/null +++ b/chart/templates/NOTES.txt @@ -0,0 +1,20 @@ +fAIr has been deployed! + +Components: + - Backend API: {{ include "fair.backend.fullname" . }} +{{- if .Values.backend.djangoQ.enabled }} + - Django-Q: running as sidecar in backend pod +{{- end }} + - PostgreSQL: external ({{ .Values.externalDatabase.host }}) + +{{- if .Values.ingress.enabled }} + +Access the application at: +{{- range .Values.ingress.hosts }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ .host }} +{{- end }} +{{- else }} + +To access the backend API, run: + kubectl port-forward svc/{{ include "fair.backend.fullname" . }} 8000:{{ .Values.backend.service.port }} +{{- end }} diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl new file mode 100644 index 000000000..da72ce2e0 --- /dev/null +++ b/chart/templates/_helpers.tpl @@ -0,0 +1,90 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "fair.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "fair.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "fair.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "fair.labels" -}} +helm.sh/chart: {{ include "fair.chart" . }} +{{ include "fair.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "fair.selectorLabels" -}} +app.kubernetes.io/name: {{ include "fair.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "fair.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "fair.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Backend fullname +*/}} +{{- define "fair.backend.fullname" -}} +{{- printf "%s-backend" (include "fair.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Backend selector labels +*/}} +{{- define "fair.backend.selectorLabels" -}} +{{ include "fair.selectorLabels" . }} +app.kubernetes.io/component: backend +{{- end }} + +{{/* +Backend image +*/}} +{{- define "fair.backend.image" -}} +{{- $tag := default .Chart.AppVersion .Values.image.backend.tag -}} +{{- printf "%s:%s" .Values.image.backend.repository $tag }} +{{- end }} + +{{/* +DATABASE_URL for Django +*/}} +{{- define "fair.databaseUrl" -}} +postgis://$(DATABASE_USER):$(DATABASE_PASSWORD)@{{ .Values.externalDatabase.host }}:{{ .Values.externalDatabase.port | toString }}/{{ .Values.externalDatabase.database }} +{{- end }} diff --git a/chart/templates/backend/configmap.yaml b/chart/templates/backend/configmap.yaml new file mode 100644 index 000000000..e0bd88f45 --- /dev/null +++ b/chart/templates/backend/configmap.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "fair.backend.fullname" . }} + labels: + {{- include "fair.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +data: + DATABASE_HOST: {{ .Values.externalDatabase.host | quote }} + DATABASE_PORT: {{ .Values.externalDatabase.port | toString | quote }} + DATABASE_NAME: {{ .Values.externalDatabase.database | quote }} + DATABASE_USER: {{ .Values.externalDatabase.username | quote }} + {{- range $key, $value := .Values.backend.env }} + {{ $key }}: {{ $value | quote }} + {{- end }} diff --git a/chart/templates/backend/deployment.yaml b/chart/templates/backend/deployment.yaml new file mode 100644 index 000000000..56ca9e03d --- /dev/null +++ b/chart/templates/backend/deployment.yaml @@ -0,0 +1,126 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "fair.backend.fullname" . }} + labels: + {{- include "fair.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + replicas: {{ .Values.backendReplicaCount }} + selector: + matchLabels: + {{- include "fair.backend.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/backend/configmap.yaml") . | sha256sum }} + {{- with .Values.backend.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "fair.backend.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "fair.serviceAccountName" . }} + {{- with .Values.backend.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: api + image: {{ include "fair.backend.image" . }} + imagePullPolicy: {{ .Values.image.backend.pullPolicy }} + {{- with .Values.backend.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.backend.port }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "fair.backend.fullname" . }} + {{- with .Values.backend.envFrom }} + {{- toYaml . | nindent 12 }} + {{- end }} + env: + - name: DATABASE_PASSWORD + valueFrom: + secretKeyRef: + {{- if .Values.externalDatabase.existingSecret }} + name: {{ .Values.externalDatabase.existingSecret }} + key: {{ .Values.externalDatabase.existingSecretKey }} + {{- else }} + name: {{ include "fair.backend.fullname" . }}-db + key: password + {{- end }} + - name: DATABASE_URL + value: {{ include "fair.databaseUrl" . | quote }} + {{- with .Values.backend.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if .Values.backend.djangoQ.enabled }} + - name: django-q + image: {{ include "fair.backend.image" . }} + imagePullPolicy: {{ .Values.image.backend.pullPolicy }} + command: + - python + - manage.py + - qcluster + envFrom: + - configMapRef: + name: {{ include "fair.backend.fullname" . }} + {{- with .Values.backend.envFrom }} + {{- toYaml . | nindent 12 }} + {{- end }} + env: + - name: DATABASE_PASSWORD + valueFrom: + secretKeyRef: + {{- if .Values.externalDatabase.existingSecret }} + name: {{ .Values.externalDatabase.existingSecret }} + key: {{ .Values.externalDatabase.existingSecretKey }} + {{- else }} + name: {{ include "fair.backend.fullname" . }}-db + key: password + {{- end }} + - name: DATABASE_URL + value: {{ include "fair.databaseUrl" . | quote }} + {{- with .Values.backend.djangoQ.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- end }} + {{- with .Values.backend.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/chart/templates/backend/migrate-job.yaml b/chart/templates/backend/migrate-job.yaml new file mode 100644 index 000000000..0e9e44734 --- /dev/null +++ b/chart/templates/backend/migrate-job.yaml @@ -0,0 +1,53 @@ +{{- if .Values.backend.migrate.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "fair.backend.fullname" . }}-migrate + labels: + {{- include "fair.labels" . | nindent 4 }} + app.kubernetes.io/component: backend + annotations: + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-weight": "-5" + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + template: + metadata: + labels: + {{- include "fair.backend.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "fair.serviceAccountName" . }} + restartPolicy: OnFailure + containers: + - name: migrate + image: {{ include "fair.backend.image" . }} + imagePullPolicy: {{ .Values.image.backend.pullPolicy }} + command: + - python + - manage.py + - migrate + - --noinput + envFrom: + - configMapRef: + name: {{ include "fair.backend.fullname" . }} + {{- with .Values.backend.envFrom }} + {{- toYaml . | nindent 12 }} + {{- end }} + env: + - name: DATABASE_PASSWORD + valueFrom: + secretKeyRef: + {{- if .Values.externalDatabase.existingSecret }} + name: {{ .Values.externalDatabase.existingSecret }} + key: {{ .Values.externalDatabase.existingSecretKey }} + {{- else }} + name: {{ include "fair.backend.fullname" . }}-db + key: password + {{- end }} + - name: DATABASE_URL + value: {{ include "fair.databaseUrl" . | quote }} +{{- end }} diff --git a/chart/templates/backend/secret.yaml b/chart/templates/backend/secret.yaml new file mode 100644 index 000000000..a89635420 --- /dev/null +++ b/chart/templates/backend/secret.yaml @@ -0,0 +1,12 @@ +{{- if not .Values.externalDatabase.existingSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "fair.backend.fullname" . }}-db + labels: + {{- include "fair.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +type: Opaque +data: + password: {{ .Values.externalDatabase.password | b64enc | quote }} +{{- end }} diff --git a/chart/templates/backend/service.yaml b/chart/templates/backend/service.yaml new file mode 100644 index 000000000..4bd6d696b --- /dev/null +++ b/chart/templates/backend/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "fair.backend.fullname" . }} + labels: + {{- include "fair.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + type: {{ .Values.backend.service.type }} + ports: + - port: {{ .Values.backend.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "fair.backend.selectorLabels" . | nindent 4 }} diff --git a/chart/templates/ingress.yaml b/chart/templates/ingress.yaml new file mode 100644 index 000000000..3ddee9cbb --- /dev/null +++ b/chart/templates/ingress.yaml @@ -0,0 +1,41 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "fair.fullname" . }} + labels: + {{- include "fair.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "fair.backend.fullname" $ }} + port: + name: http + {{- end }} + {{- end }} +{{- end }} diff --git a/chart/templates/serviceaccount.yaml b/chart/templates/serviceaccount.yaml new file mode 100644 index 000000000..275a251e6 --- /dev/null +++ b/chart/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "fair.serviceAccountName" . }} + labels: + {{- include "fair.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/chart/values.yaml b/chart/values.yaml new file mode 100644 index 000000000..c5ec6a8a6 --- /dev/null +++ b/chart/values.yaml @@ -0,0 +1,116 @@ +# -- Number of backend replicas +backendReplicaCount: 1 + +image: + backend: + repository: ghcr.io/hotosm/fair-api + tag: "" # defaults to appVersion + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + annotations: {} + name: "" + +# -- Backend (Django API) configuration +backend: + command: + - gunicorn + - --bind=0.0.0.0:8000 + - --workers=3 + - --timeout=120 + - fairproject.wsgi:application + + # -- Run Django-Q cluster as a sidecar for lightweight async tasks + djangoQ: + enabled: true + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 250m + memory: 512Mi + + port: 8000 + + env: {} + # DJANGO_SECRET_KEY: "" + # DJANGO_ALLOWED_HOSTS: "*" + # OSM_CLIENT_ID: "" + # OSM_CLIENT_SECRET: "" + # OSM_SECRET_KEY: "" + + # -- Reference an existing Secret for sensitive env vars + envFrom: [] + # - secretRef: + # name: fair-backend-secrets + + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 500m + memory: 512Mi + + livenessProbe: + httpGet: + path: /api/ + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + + readinessProbe: + httpGet: + path: /api/ + port: http + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 5 + + nodeSelector: {} + tolerations: [] + affinity: {} + podAnnotations: {} + podSecurityContext: {} + securityContext: {} + + # -- Run Django migrations as a pre-install/upgrade hook + migrate: + enabled: true + + service: + type: ClusterIP + port: 8000 + +# -- Ingress configuration +ingress: + enabled: false + className: "" + annotations: {} + # nginx.ingress.kubernetes.io/proxy-body-size: "50m" + hosts: + - host: fair.example.com + paths: + - path: /api + pathType: Prefix + tls: [] + # - secretName: fair-tls + # hosts: + # - fair.example.com + +# -- External PostgreSQL connection +externalDatabase: + host: "" + port: 5432 + database: ai + username: postgres + password: "" + existingSecret: "" + existingSecretKey: password diff --git a/tasks/chart b/tasks/chart new file mode 100644 index 000000000..e76fce76f --- /dev/null +++ b/tasks/chart @@ -0,0 +1,195 @@ +# List available commands +[private] +default: + just --justfile {{justfile()}} --list chart + +# Publish the packaged chart to an OCI registry. +# Registry URL is resolved in the script from env, not from Justfile interpolation. +# Defaults to ghcr.io//charts, which results in charts at ghcr.io//charts/ +publish registry="": build + #!/usr/bin/env bash + set -e + + cd {{justfile_directory()}} + + echo "🔐 Logging in to Helm registry..." + # Resolve full registry path: + # 1. CLI arg: `just chart publish registry="ghcr.io/org/charts"` + # 2. Env: HELM_REGISTRY (e.g. "ghcr.io/hotosm/charts") + # 3. Default: ghcr.io//charts + REGISTRY="{{ registry }}" + if [ -z "$REGISTRY" ]; then + if [ -n "${HELM_REGISTRY:-}" ]; then + REGISTRY="${HELM_REGISTRY}" + else + OWNER="${HELM_REGISTRY_OWNER:-${GITHUB_REPOSITORY_OWNER:-}}" + if [ -z "$OWNER" ]; then + echo "❌ Unable to determine chart registry owner." + echo " Provide one of:" + echo " - just chart publish registry=\"ghcr.io//charts\"" + echo " - HELM_REGISTRY=\"ghcr.io//charts\"" + echo " - HELM_REGISTRY_OWNER or GITHUB_REPOSITORY_OWNER" + exit 1 + fi + REGISTRY="ghcr.io/${OWNER}/charts" + fi + fi + + REGISTRY_HOST="$(printf '%s\n' "$REGISTRY" | cut -d/ -f1)" + echo "REGISTRY: ${REGISTRY}" + echo "REGISTRY_HOST: ${REGISTRY_HOST}" + + # Prefer explicit Helm registry creds, fall back to GitHub defaults in CI + USERNAME="${HELM_REGISTRY_USERNAME:-${GITHUB_ACTOR:-}}" + PASSWORD="${HELM_REGISTRY_PASSWORD:-${GITHUB_TOKEN:-}}" + + if [ -z "$USERNAME" ] || [ -z "$PASSWORD" ]; then + echo "❌ Missing registry credentials." + echo " Set HELM_REGISTRY_USERNAME / HELM_REGISTRY_PASSWORD" + echo " or rely on GITHUB_ACTOR / GITHUB_TOKEN in GitHub Actions." + exit 1 + fi + + echo "$PASSWORD" | helm registry login "$REGISTRY_HOST" \ + --username "$USERNAME" \ + --password-stdin + + CHART_FILE=$(ls -t fair-*.tgz | head -1) + if [ -z "$CHART_FILE" ]; then + echo "❌ No chart file found. Run build first" + exit 1 + fi + + echo "🚀 Publishing chart to OCI registry..." + echo "📦 Chart file: $CHART_FILE" + + echo "🔎 Checking if chart version already exists in registry..." + CHART_VERSION=$(helm show chart "$CHART_FILE" | grep '^version:' | awk '{ print $2 }') + if helm show chart "oci://$REGISTRY/fair" --version "$CHART_VERSION" >/dev/null 2>&1; then + echo "ℹ️ Chart version $CHART_VERSION already exists in OCI registry." + echo " Skipping push." + exit 0 + fi + + echo "📤 Pushing to: oci://$REGISTRY" + helm push "$CHART_FILE" "oci://$REGISTRY" + + echo "✅ Chart published!" + +# Build the Helm chart: lint, update deps, test, and package +build: _install-helm lint dependencies package + +# Ensure Helm is available (module-local copy to avoid nested `just` calls). +[private] +_install-helm: + #!/usr/bin/env bash + set -e + + if command -v helm &> /dev/null; then + exit 0 + fi + + echo "📦 Installing Helm..." + + # Only Linux / amd64 automated install for now; otherwise instruct user + UNAME_S="$(uname -s || echo unknown)" + UNAME_M="$(uname -m || echo unknown)" + + if [ "$UNAME_S" != "Linux" ] || { [ "$UNAME_M" != "x86_64" ] && [ "$UNAME_M" != "amd64" ]; }; then + echo "❌ Automatic Helm install only supported on Linux amd64." + echo " Please install Helm manually: https://helm.sh/docs/intro/install/" + exit 1 + fi + + TMP_DIR="$(mktemp -d)" + trap 'rm -rf "$TMP_DIR"' EXIT + + # Get latest Helm release tag + HELM_TAG="$(curl -sSL https://api.github.com/repos/helm/helm/releases/latest | grep -oE '\"tag_name\":\s*\"v[0-9.]+\"' | head -1 | sed -E 's/\"tag_name\":\s*\"(v[0-9.]+)\"/\1/')" + if [ -z "$HELM_TAG" ]; then + echo "❌ Failed to determine latest Helm version." + exit 1 + fi + + ARCHIVE="helm-${HELM_TAG}-linux-amd64.tar.gz" + URL="https://get.helm.sh/${ARCHIVE}" + + echo "⬇️ Downloading ${URL}..." + curl -sSL "$URL" -o "$TMP_DIR/helm.tar.gz" + tar -xzf "$TMP_DIR/helm.tar.gz" -C "$TMP_DIR" + + if sudo mv "$TMP_DIR/linux-amd64/helm" /usr/local/bin/helm 2>/dev/null; then + chmod +x /usr/local/bin/helm + echo "✓ Helm installed to /usr/local/bin/helm" + else + mkdir -p "$HOME/.local/bin" + mv "$TMP_DIR/linux-amd64/helm" "$HOME/.local/bin/helm" + chmod +x "$HOME/.local/bin/helm" + export PATH="$HOME/.local/bin:$PATH" + echo "✓ Helm installed to ~/.local/bin/helm" + fi + + if ! command -v helm &> /dev/null; then + echo "❌ Error: Failed to install Helm" + exit 1 + fi + +# Lint the Helm chart +lint: + #!/usr/bin/env sh + set -e + + cd {{justfile_directory()}} + echo "🔍 Linting chart..." + helm lint chart + echo "✓ Lint passed" + echo "" + +# Update chart dependences +dependencies: + #!/usr/bin/env sh + set -e + + cd {{justfile_directory()}}/chart + echo "📥 Updating dependencies..." + helm dependency update + cd {{justfile_directory()}} + echo "✓ Dependencies updated" + echo "" + +# Package the Helm chart +package: + #!/usr/bin/env bash + set -e + + cd {{justfile_directory()}} + echo "📦 Packaging chart..." + helm package chart + PACKAGE=$(ls -t fair-*.tgz | head -1) + echo "✓ Chart packaged: $PACKAGE" + echo "" + echo "✅ Build complete!" + +# Render chart templates +template: + #!/usr/bin/env bash + set -e + cd {{justfile_directory()}} + helm template fair chart + +# Show chart information +show: + #!/usr/bin/env bash + set -e + cd {{justfile_directory()}} + helm show all chart + +# Clean up packaged charts +clean: + #!/usr/bin/env bash + set -e + + cd {{justfile_directory()}} + rm -f fair-*.tgz + rm -rf ./chart/charts/ + echo "✓ Cleaned up chart artifacts" From 2d6c1e9f1a68f1e29cbb1ac67a69730a6c9f7a5a Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Mon, 20 Apr 2026 22:29:44 +0100 Subject: [PATCH 26/62] build: replace custom helm chart build justfile with reusable --- Justfile | 8 +- tasks/.gitignore | 2 + tasks/chart | 195 ----------------------------------------------- 3 files changed, 8 insertions(+), 197 deletions(-) create mode 100644 tasks/.gitignore delete mode 100644 tasks/chart diff --git a/Justfile b/Justfile index 84d55da4c..e4c11537a 100644 --- a/Justfile +++ b/Justfile @@ -1,7 +1,5 @@ set dotenv-load -mod chart 'tasks/chart' - # List available commands [private] default: @@ -11,6 +9,12 @@ default: help: just --justfile {{justfile()}} --list +# Chart module from https://github.com/hotosm/justfiles +chart *args: + @curl -sS https://raw.githubusercontent.com/hotosm/justfiles/main/chart.just \ + -o {{justfile_directory()}}/tasks/chart.just; + @just --justfile {{justfile_directory()}}/tasks/chart.just --set chart_name "fair" {{args}} + # Echo to terminal with blue colour [no-cd] _echo-blue text: diff --git a/tasks/.gitignore b/tasks/.gitignore new file mode 100644 index 000000000..ca89447c6 --- /dev/null +++ b/tasks/.gitignore @@ -0,0 +1,2 @@ +# Justfiles imported from https://github.com/hotosm/justfiles +*.just diff --git a/tasks/chart b/tasks/chart deleted file mode 100644 index e76fce76f..000000000 --- a/tasks/chart +++ /dev/null @@ -1,195 +0,0 @@ -# List available commands -[private] -default: - just --justfile {{justfile()}} --list chart - -# Publish the packaged chart to an OCI registry. -# Registry URL is resolved in the script from env, not from Justfile interpolation. -# Defaults to ghcr.io//charts, which results in charts at ghcr.io//charts/ -publish registry="": build - #!/usr/bin/env bash - set -e - - cd {{justfile_directory()}} - - echo "🔐 Logging in to Helm registry..." - # Resolve full registry path: - # 1. CLI arg: `just chart publish registry="ghcr.io/org/charts"` - # 2. Env: HELM_REGISTRY (e.g. "ghcr.io/hotosm/charts") - # 3. Default: ghcr.io//charts - REGISTRY="{{ registry }}" - if [ -z "$REGISTRY" ]; then - if [ -n "${HELM_REGISTRY:-}" ]; then - REGISTRY="${HELM_REGISTRY}" - else - OWNER="${HELM_REGISTRY_OWNER:-${GITHUB_REPOSITORY_OWNER:-}}" - if [ -z "$OWNER" ]; then - echo "❌ Unable to determine chart registry owner." - echo " Provide one of:" - echo " - just chart publish registry=\"ghcr.io//charts\"" - echo " - HELM_REGISTRY=\"ghcr.io//charts\"" - echo " - HELM_REGISTRY_OWNER or GITHUB_REPOSITORY_OWNER" - exit 1 - fi - REGISTRY="ghcr.io/${OWNER}/charts" - fi - fi - - REGISTRY_HOST="$(printf '%s\n' "$REGISTRY" | cut -d/ -f1)" - echo "REGISTRY: ${REGISTRY}" - echo "REGISTRY_HOST: ${REGISTRY_HOST}" - - # Prefer explicit Helm registry creds, fall back to GitHub defaults in CI - USERNAME="${HELM_REGISTRY_USERNAME:-${GITHUB_ACTOR:-}}" - PASSWORD="${HELM_REGISTRY_PASSWORD:-${GITHUB_TOKEN:-}}" - - if [ -z "$USERNAME" ] || [ -z "$PASSWORD" ]; then - echo "❌ Missing registry credentials." - echo " Set HELM_REGISTRY_USERNAME / HELM_REGISTRY_PASSWORD" - echo " or rely on GITHUB_ACTOR / GITHUB_TOKEN in GitHub Actions." - exit 1 - fi - - echo "$PASSWORD" | helm registry login "$REGISTRY_HOST" \ - --username "$USERNAME" \ - --password-stdin - - CHART_FILE=$(ls -t fair-*.tgz | head -1) - if [ -z "$CHART_FILE" ]; then - echo "❌ No chart file found. Run build first" - exit 1 - fi - - echo "🚀 Publishing chart to OCI registry..." - echo "📦 Chart file: $CHART_FILE" - - echo "🔎 Checking if chart version already exists in registry..." - CHART_VERSION=$(helm show chart "$CHART_FILE" | grep '^version:' | awk '{ print $2 }') - if helm show chart "oci://$REGISTRY/fair" --version "$CHART_VERSION" >/dev/null 2>&1; then - echo "ℹ️ Chart version $CHART_VERSION already exists in OCI registry." - echo " Skipping push." - exit 0 - fi - - echo "📤 Pushing to: oci://$REGISTRY" - helm push "$CHART_FILE" "oci://$REGISTRY" - - echo "✅ Chart published!" - -# Build the Helm chart: lint, update deps, test, and package -build: _install-helm lint dependencies package - -# Ensure Helm is available (module-local copy to avoid nested `just` calls). -[private] -_install-helm: - #!/usr/bin/env bash - set -e - - if command -v helm &> /dev/null; then - exit 0 - fi - - echo "📦 Installing Helm..." - - # Only Linux / amd64 automated install for now; otherwise instruct user - UNAME_S="$(uname -s || echo unknown)" - UNAME_M="$(uname -m || echo unknown)" - - if [ "$UNAME_S" != "Linux" ] || { [ "$UNAME_M" != "x86_64" ] && [ "$UNAME_M" != "amd64" ]; }; then - echo "❌ Automatic Helm install only supported on Linux amd64." - echo " Please install Helm manually: https://helm.sh/docs/intro/install/" - exit 1 - fi - - TMP_DIR="$(mktemp -d)" - trap 'rm -rf "$TMP_DIR"' EXIT - - # Get latest Helm release tag - HELM_TAG="$(curl -sSL https://api.github.com/repos/helm/helm/releases/latest | grep -oE '\"tag_name\":\s*\"v[0-9.]+\"' | head -1 | sed -E 's/\"tag_name\":\s*\"(v[0-9.]+)\"/\1/')" - if [ -z "$HELM_TAG" ]; then - echo "❌ Failed to determine latest Helm version." - exit 1 - fi - - ARCHIVE="helm-${HELM_TAG}-linux-amd64.tar.gz" - URL="https://get.helm.sh/${ARCHIVE}" - - echo "⬇️ Downloading ${URL}..." - curl -sSL "$URL" -o "$TMP_DIR/helm.tar.gz" - tar -xzf "$TMP_DIR/helm.tar.gz" -C "$TMP_DIR" - - if sudo mv "$TMP_DIR/linux-amd64/helm" /usr/local/bin/helm 2>/dev/null; then - chmod +x /usr/local/bin/helm - echo "✓ Helm installed to /usr/local/bin/helm" - else - mkdir -p "$HOME/.local/bin" - mv "$TMP_DIR/linux-amd64/helm" "$HOME/.local/bin/helm" - chmod +x "$HOME/.local/bin/helm" - export PATH="$HOME/.local/bin:$PATH" - echo "✓ Helm installed to ~/.local/bin/helm" - fi - - if ! command -v helm &> /dev/null; then - echo "❌ Error: Failed to install Helm" - exit 1 - fi - -# Lint the Helm chart -lint: - #!/usr/bin/env sh - set -e - - cd {{justfile_directory()}} - echo "🔍 Linting chart..." - helm lint chart - echo "✓ Lint passed" - echo "" - -# Update chart dependences -dependencies: - #!/usr/bin/env sh - set -e - - cd {{justfile_directory()}}/chart - echo "📥 Updating dependencies..." - helm dependency update - cd {{justfile_directory()}} - echo "✓ Dependencies updated" - echo "" - -# Package the Helm chart -package: - #!/usr/bin/env bash - set -e - - cd {{justfile_directory()}} - echo "📦 Packaging chart..." - helm package chart - PACKAGE=$(ls -t fair-*.tgz | head -1) - echo "✓ Chart packaged: $PACKAGE" - echo "" - echo "✅ Build complete!" - -# Render chart templates -template: - #!/usr/bin/env bash - set -e - cd {{justfile_directory()}} - helm template fair chart - -# Show chart information -show: - #!/usr/bin/env bash - set -e - cd {{justfile_directory()}} - helm show all chart - -# Clean up packaged charts -clean: - #!/usr/bin/env bash - set -e - - cd {{justfile_directory()}} - rm -f fair-*.tgz - rm -rf ./chart/charts/ - echo "✓ Cleaned up chart artifacts" From 9fb7b45f8dd1f2333d46b79d884fc76e0e7dcda5 Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Sun, 10 May 2026 22:23:43 +0200 Subject: [PATCH 27/62] feat: stacified base models --- frontend/.env.sample | 13 +- frontend/src/app/providers/auth-provider.tsx | 4 +- .../routes/base-models/base-model-detail.tsx | 34 +- .../routes/base-models/base-models-list.tsx | 97 +++-- frontend/src/config/env.ts | 1 + frontend/src/config/index.ts | 8 + .../base-models/api/get-base-models.ts | 22 + .../components/base-model-card.tsx | 2 +- .../components/base-models-filters.tsx | 32 +- .../components/mobile-base-model-filters.tsx | 13 +- .../base-models/hooks/use-base-models.ts | 27 ++ .../src/features/base-models/layouts/grid.tsx | 2 +- .../features/base-models/layouts/table.tsx | 4 +- .../src/features/base-models/utils/common.ts | 4 + .../src/features/base-models/utils/stac.ts | 108 +++++ frontend/src/services/api-client.ts | 5 + frontend/src/services/api-routes.ts | 7 + frontend/src/styles/index.css | 2 - frontend/src/types/api.ts | 17 + frontend/src/utils/base-model-data.ts | 394 ------------------ 20 files changed, 325 insertions(+), 471 deletions(-) create mode 100644 frontend/src/features/base-models/api/get-base-models.ts create mode 100644 frontend/src/features/base-models/hooks/use-base-models.ts create mode 100644 frontend/src/features/base-models/utils/common.ts create mode 100644 frontend/src/features/base-models/utils/stac.ts delete mode 100644 frontend/src/utils/base-model-data.ts diff --git a/frontend/.env.sample b/frontend/.env.sample index 5400d89b5..f77a9c3e7 100644 --- a/frontend/.env.sample +++ b/frontend/.env.sample @@ -254,4 +254,15 @@ MAPSWIPE_VERIFICATION_NUMBER = 4 # The group size for MapSwipe projects. # Data type: Positive Integer (e.g., 25). # Default value: 25. -MAPSWIPE_GROUP_SIZE = 25 \ No newline at end of file +MAPSWIPE_GROUP_SIZE = 25 + + +# The Hanko authentication token for user authentication. ####### ONLY NEEDED IN DEVELOPMENT ENVIRONMENT. ######## +# Data type: String (e.g., "2mKa-_KJR9ak1"). +# Default value: "2mKa-_KJR9". +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/" \ No newline at end of file diff --git a/frontend/src/app/providers/auth-provider.tsx b/frontend/src/app/providers/auth-provider.tsx index eec2de5c7..02e085ea3 100644 --- a/frontend/src/app/providers/auth-provider.tsx +++ b/frontend/src/app/providers/auth-provider.tsx @@ -67,8 +67,8 @@ export const AuthProvider: React.FC = ({ children }) => { if (AUTH_PROVIDER === "hanko") { apiClient.defaults.withCredentials = true; } else { - apiClient.defaults.headers.common["access-token"] = token - ? `${token}` + apiClient.defaults.headers.common["Authorization"] = token + ? `Bearer ${token}` : null; } diff --git a/frontend/src/app/routes/base-models/base-model-detail.tsx b/frontend/src/app/routes/base-models/base-model-detail.tsx index a26c2272e..783cf8b39 100644 --- a/frontend/src/app/routes/base-models/base-model-detail.tsx +++ b/frontend/src/app/routes/base-models/base-model-detail.tsx @@ -8,13 +8,10 @@ import { APPLICATION_ROUTES } from "@/constants"; import { ButtonVariant } from "@/enums"; import AccuracyDisplay from "@/features/models/components/accuracy-display"; -import { - BASE_MODELS_DETAIL_DATA, - TBaseModelDetail, - TBaseModelVariant, -} from "@/utils/base-model-data"; -import { useEffect, useMemo, useState } from "react"; +import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import { useBaseModel } from "@/features/base-models/hooks/use-base-models"; +import { TBaseModelVariant } from "@/types"; type TInfoRowConfig = { label: string; @@ -121,23 +118,15 @@ export const BaseModelDetailPage = () => { const { id } = useParams(); const navigate = useNavigate(); - const model: TBaseModelDetail | undefined = useMemo(() => { - return BASE_MODELS_DETAIL_DATA.find((m) => String(m.id) === id); - }, [id]); + const { data: model, isLoading, isError } = useBaseModel(id); - useEffect(() => { - if (!model) { - navigate(APPLICATION_ROUTES.NOTFOUND, { - replace: true, - state: { - from: APPLICATION_ROUTES.BASE_MODELS_HOME, - error: "base model not found", - buttonLabel: "Back to Base Models", - redirectPath: APPLICATION_ROUTES.BASE_MODELS_HOME, - }, - }); - } - }, [model, navigate]); + if (isLoading) { + return
      Loading model...
      ; + } + + if (isError || !model) { + return
      Failed to load model
      ; + } const architectureRows: TInfoRowConfig[] = model ? [ @@ -206,6 +195,7 @@ export const BaseModelDetailPage = () => { return null; } + console.log("Model data:", model); return ( <> diff --git a/frontend/src/app/routes/base-models/base-models-list.tsx b/frontend/src/app/routes/base-models/base-models-list.tsx index 7eb6fcbca..13339cf0e 100644 --- a/frontend/src/app/routes/base-models/base-models-list.tsx +++ b/frontend/src/app/routes/base-models/base-models-list.tsx @@ -3,11 +3,6 @@ import { ButtonWithIcon } from "@/components/ui/button"; import { AddIcon } from "@/components/ui/icons"; import { SHARED_CONTENT } from "@/constants"; import { ButtonVariant, LayoutView } from "@/enums"; -import { - BASE_MODELS_DATA, - TASK_CATEGORIES, - DATE_SORT_OPTIONS, -} from "@/utils/base-model-data"; import { useDialog } from "@/hooks/use-dialog"; import { useMemo } from "react"; import { parseAsString, useQueryStates } from "nuqs"; @@ -20,6 +15,9 @@ import { BaseModelGridLayout, BaseModelTableLayout, } from "@/features/base-models/layouts"; +import { useBaseModels } from "@/features/base-models/hooks/use-base-models"; +import { TBaseModel } from "@/types"; +import { DATE_SORT_OPTIONS } from "@/features/base-models/utils/common"; export const BaseModelsPage = () => { const { isOpened, openDialog, closeDialog } = useDialog(); @@ -29,7 +27,7 @@ export const BaseModelsPage = () => { openDialog: openMobileFilters, closeDialog: closeMobileFilters, } = useDialog(); - // nuqs-powered search params state + const [{ q: search, category, date: dateSort, layout }, setQueryStates] = useQueryStates({ q: parseAsString.withDefault(""), @@ -37,16 +35,42 @@ export const BaseModelsPage = () => { date: parseAsString.withDefault("newest"), layout: parseAsString.withDefault(LayoutView.GRID), }); + const isListView = layout === LayoutView.LIST; - // Filter and sort models + const { data: models = [], isLoading, isError } = useBaseModels(); + + /** + * 1. Dynamically derive categories from STAC models + */ + const taskCategories = useMemo(() => { + const uniqueTasks = new Set(); + + models.forEach((m: TBaseModel) => { + if (m.task) uniqueTasks.add(m.task); + }); + + const list = Array.from(uniqueTasks).sort(); + + return [ + { label: "All", value: "all" }, + ...list.map((t) => ({ + label: t, + value: t, + })), + ]; + }, [models]); + + /** + * 2. Filters + sorting + */ const filteredModels = useMemo(() => { - let models = [...BASE_MODELS_DATA]; + let result = [...models]; - // Search filter if (search) { const searchLower = search.toLowerCase(); - models = models.filter( + + result = result.filter( (model) => model.name.toLowerCase().includes(searchLower) || model.description.toLowerCase().includes(searchLower) || @@ -54,36 +78,42 @@ export const BaseModelsPage = () => { ); } - // Category filter - if (category !== "all") { - models = models.filter((model) => model.task === category); + if (category && category !== "all") { + result = result.filter((model) => model.task === category); } - // Date sorting - if (dateSort === "oldest") { - models.reverse(); - } + result.sort((a, b) => { + const aDate = new Date(a.updatedAt).getTime(); + const bDate = new Date(b.updatedAt).getTime(); - return models; - }, [search, category, dateSort]); + if (dateSort === "oldest") return aDate - bDate; + return bDate - aDate; + }); - // Category dropdown items - const categoryMenuItems = TASK_CATEGORIES.map((cat) => ({ - value: cat.label, - apiValue: cat.value, - })); + return result; + }, [models, search, category, dateSort]); + + /** + * 3. Dropdown items (dynamic category) + */ + const categoryMenuItems = useMemo(() => { + return taskCategories.map((cat) => ({ + value: cat.label, + apiValue: cat.value, + })); + }, [taskCategories]); - // Date dropdown items const dateMenuItems = DATE_SORT_OPTIONS.map((opt) => ({ value: opt.label, apiValue: opt.value, })); const selectedCategoryLabel = - TASK_CATEGORIES.find((c) => c.value === category)?.label || "Category"; + taskCategories.find((c) => c.value === category)?.label || "Category"; const selectedDateLabel = DATE_SORT_OPTIONS.find((d) => d.value === dateSort)?.label || "Date"; + const toggleLayout = () => { setQueryStates({ layout: isListView ? LayoutView.GRID : LayoutView.LIST, @@ -91,6 +121,14 @@ export const BaseModelsPage = () => { }; const renderContent = () => { + if (isLoading) { + return
      Loading models...
      ; + } + + if (isError) { + return
      Failed to load models
      ; + } + if (filteredModels.length === 0) { return (
      @@ -112,10 +150,13 @@ export const BaseModelsPage = () => { return ; }; + return ( <> + + { setCategory={(value) => setQueryStates({ category: value })} setDateSort={(value) => setQueryStates({ date: value })} /> +
      - {/* Header */}

      {SHARED_CONTENT.baseModelsPage.pageHeadingTitle}

      +
      { />
      +

      {SHARED_CONTENT.baseModelsPage.pageHeadingDescription}

      diff --git a/frontend/src/config/env.ts b/frontend/src/config/env.ts index 5c31afec2..4da22b70a 100644 --- a/frontend/src/config/env.ts +++ b/frontend/src/config/env.ts @@ -104,4 +104,5 @@ export const ENVS = { MAPSWIPE_VERIFICATION_NUMBER: import.meta.env .VITE_MAPSWIPE_VERIFICATION_NUMBER, MAPSWIPE_GROUP_SIZE: import.meta.env.VITE_MAPSWIPE_GROUP_SIZE, + FAIR_STAC_CATALOG_BASE_URL: import.meta.env.VITE_FAIR_STAC_CATALOG_BASE_URL, }; diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts index daa559e5d..8678aac31 100644 --- a/frontend/src/config/index.ts +++ b/frontend/src/config/index.ts @@ -64,6 +64,14 @@ export const BASE_API_URL: string = parseStringEnv( "http://localhost:8000/api/v1/", ); +/** + * The STAC Catalog API endpoint url. This is used to fetch the base models, datasets and local models as STAC items from the catalog. + */ +export const STAC_CATALOG_API_URL: string = parseStringEnv( + ENVS.FAIR_STAC_CATALOG_BASE_URL, + "https://stac.fair.krschap.tech/stac/", +); + /** * The Base URL for OAM's Titiler. */ diff --git a/frontend/src/features/base-models/api/get-base-models.ts b/frontend/src/features/base-models/api/get-base-models.ts new file mode 100644 index 000000000..137569602 --- /dev/null +++ b/frontend/src/features/base-models/api/get-base-models.ts @@ -0,0 +1,22 @@ +import { API_ENDPOINTS, stacClient } from "@/services"; + +export type TGetBaseModelsParams = { + limit?: number; + page?: number; +}; + +export const getBaseModels = async ({ + limit = 20, +}: TGetBaseModelsParams = {}) => { + const res = await stacClient.get(API_ENDPOINTS.GET_BASE_MODELS(limit)); + return { + ...res.data, + hasNext: res.data.next, + hasPrev: res.data.previous, + }; +}; + +export const getBaseModelById = async (id: string) => { + const res = await stacClient.get(API_ENDPOINTS.GET_BASE_MODEL_BY_ID(id)); + return res.data; +}; diff --git a/frontend/src/features/base-models/components/base-model-card.tsx b/frontend/src/features/base-models/components/base-model-card.tsx index de365bdac..00c5cb823 100644 --- a/frontend/src/features/base-models/components/base-model-card.tsx +++ b/frontend/src/features/base-models/components/base-model-card.tsx @@ -1,6 +1,6 @@ import { Link } from "@/components/ui/link"; import { APPLICATION_ROUTES } from "@/constants"; -import { TBaseModel } from "@/utils/base-model-data"; +import { TBaseModel } from "@/types"; import { roundNumber } from "@/utils/number-utils"; type BaseModelCardProps = { diff --git a/frontend/src/features/base-models/components/base-models-filters.tsx b/frontend/src/features/base-models/components/base-models-filters.tsx index 6b61aa2e2..b3abe8a17 100644 --- a/frontend/src/features/base-models/components/base-models-filters.tsx +++ b/frontend/src/features/base-models/components/base-models-filters.tsx @@ -6,7 +6,6 @@ import { } from "@/components/ui/icons"; import { DropDown } from "@/components/ui/dropdown"; import { ToolTip } from "@/components/ui/tooltip"; -import { TASK_CATEGORIES, DATE_SORT_OPTIONS } from "@/utils/base-model-data"; import { LayoutView } from "@/enums"; type TMenuItem = { @@ -68,14 +67,15 @@ const BaseModelsFilters: React.FC = ({ menuItems={categoryMenuItems} withCheckbox handleMenuSelection={(value: string) => { - const selected = TASK_CATEGORIES.find( - (c) => c.label === value, + const selected = categoryMenuItems.find( + (c) => c.value === value, + ); + + if (!selected) return; + + setCategory( + selected.apiValue === "all" ? null : selected.apiValue, ); - if (selected) { - setCategory( - selected.value === "all" ? null : selected.value, - ); - } }} defaultSelectedItem={selectedCategoryLabel} triggerComponent={ @@ -92,14 +92,13 @@ const BaseModelsFilters: React.FC = ({ menuItems={dateMenuItems} withCheckbox handleMenuSelection={(value: string) => { - const selected = DATE_SORT_OPTIONS.find( - (d) => d.label === value, + const selected = dateMenuItems.find((d) => d.value === value); + + if (!selected) return; + + setDateSort( + selected.apiValue === "newest" ? null : selected.apiValue, ); - if (selected) { - setDateSort( - selected.value === "newest" ? null : selected.value, - ); - } }} defaultSelectedItem={selectedDateLabel} triggerComponent={ @@ -121,6 +120,7 @@ const BaseModelsFilters: React.FC = ({ >
      + {/* Desktop layout toggle */}
      @@ -145,7 +145,7 @@ const BaseModelsFilters: React.FC = ({

      {filteredModelsCount} Models

      - {/* Mobile Layout toggle */} +
      - {AUTH_PROVIDER === "hanko" ? ( + {AUTH_PROVIDER === "hanko" && !IS_DEV ? ( <> {isAuthenticated && } {isAuthenticated && } diff --git a/frontend/src/config/env.ts b/frontend/src/config/env.ts index 4da22b70a..e672ca8e0 100644 --- a/frontend/src/config/env.ts +++ b/frontend/src/config/env.ts @@ -105,4 +105,5 @@ export const ENVS = { .VITE_MAPSWIPE_VERIFICATION_NUMBER, MAPSWIPE_GROUP_SIZE: import.meta.env.VITE_MAPSWIPE_GROUP_SIZE, FAIR_STAC_CATALOG_BASE_URL: import.meta.env.VITE_FAIR_STAC_CATALOG_BASE_URL, + NODE_ENV: import.meta.env.VITE_NODE_ENV, }; diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts index 8678aac31..25c922a8f 100644 --- a/frontend/src/config/index.ts +++ b/frontend/src/config/index.ts @@ -51,6 +51,10 @@ export const FRONTEND_URL: string = ? window.location.origin : "http://localhost:5173"; +export const NODE_ENV: string = parseStringEnv(ENVS.NODE_ENV, "development"); +export const IS_DEV = NODE_ENV === "development"; +export const IS_PROD = NODE_ENV === "production"; + // ============================================================================================================================== // API Endpoints // ============================================================================================================================== diff --git a/frontend/src/services/api-routes.ts b/frontend/src/services/api-routes.ts index 5365e8b73..4af5ffadb 100644 --- a/frontend/src/services/api-routes.ts +++ b/frontend/src/services/api-routes.ts @@ -3,7 +3,6 @@ import { OSM_DATABASE_STATUS_API_ENDPOINT, } from "@/config"; - /** * The backend API endpoints. */ diff --git a/frontend/vite.config.mts b/frontend/vite.config.mts index a2a54d09f..7efabcbd1 100644 --- a/frontend/vite.config.mts +++ b/frontend/vite.config.mts @@ -13,7 +13,7 @@ export default defineConfig({ // By default it was localhost:5173, but it was causing some issues with the OAUTH, so it was changed to this. server: { host: "127.0.0.1", - port: 5173, + port: 3500, }, test: { From 3cecddd4c74fc27ee3bcfd64cb1f75a797dad064 Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Mon, 11 May 2026 21:39:26 +0200 Subject: [PATCH 29/62] chore (auth): fixed for mobile: --- frontend/src/components/layouts/navbar/navbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/layouts/navbar/navbar.tsx b/frontend/src/components/layouts/navbar/navbar.tsx index 05bf278f6..75679bdba 100644 --- a/frontend/src/components/layouts/navbar/navbar.tsx +++ b/frontend/src/components/layouts/navbar/navbar.tsx @@ -75,7 +75,7 @@ export const NavBar = () => { {isAuthenticated && }
      - {AUTH_PROVIDER === "hanko" ? ( + {AUTH_PROVIDER === "hanko" && !IS_DEV ? ( <> {isAuthenticated && ( Date: Tue, 19 May 2026 22:04:22 +0100 Subject: [PATCH 30/62] completed ui conversion --- frontend/src/app/router.tsx | 14 ++ frontend/src/app/routes/try-fair.tsx | 79 ++++++++ .../src/components/landing/header/header.tsx | 10 +- .../src/components/layouts/navbar/navbar.tsx | 6 +- .../src/components/layouts/root-layout.tsx | 1 + frontend/src/components/ui/button/button.css | 4 + frontend/src/components/ui/button/button.tsx | 5 +- .../components/ui/icons/buildings-icon.tsx | 20 ++ .../src/components/ui/icons/cluster-icon.tsx | 50 +++++ .../src/components/ui/icons/flame-icon.tsx | 23 +++ .../src/components/ui/icons/grid-icon.tsx | 16 ++ .../src/components/ui/icons/map-play-icon.tsx | 18 ++ .../components/ui/icons/parameters-icon.tsx | 35 ++++ .../src/components/ui/icons/points-icons.tsx | 28 +++ .../src/components/ui/icons/polygon-icon.tsx | 52 ++++++ .../components/ui/icons/snow-flake-icon.tsx | 65 +++++++ .../components/ui/icons/solar-panel-icon.tsx | 26 +++ .../src/components/ui/icons/trees-icon.tsx | 18 ++ frontend/src/constants/routes.ts | 2 + .../constants/ui-contents/shared-content.ts | 6 +- .../ui-contents/try-fair-contents.ts | 41 +++++ frontend/src/enums/try-fair.ts | 11 ++ .../try-fair/components/map/try-fair-map.tsx | 55 ++++++ .../components/model-picker-modal.tsx | 172 ++++++++++++++++++ .../try-fair/components/try-fair-sidebar.tsx | 169 +++++++++++++++++ frontend/src/features/try-fair/models.ts | 65 +++++++ .../src/features/try-fair/utils/constants.tsx | 46 +++++ frontend/src/hooks/use-tileservice.ts | 6 + frontend/src/styles/index.css | 20 ++ frontend/src/types/ui-contents.ts | 45 +++++ 30 files changed, 1097 insertions(+), 11 deletions(-) create mode 100644 frontend/src/app/routes/try-fair.tsx create mode 100644 frontend/src/components/ui/icons/buildings-icon.tsx create mode 100644 frontend/src/components/ui/icons/cluster-icon.tsx create mode 100644 frontend/src/components/ui/icons/flame-icon.tsx create mode 100644 frontend/src/components/ui/icons/grid-icon.tsx create mode 100644 frontend/src/components/ui/icons/map-play-icon.tsx create mode 100644 frontend/src/components/ui/icons/parameters-icon.tsx create mode 100644 frontend/src/components/ui/icons/points-icons.tsx create mode 100644 frontend/src/components/ui/icons/polygon-icon.tsx create mode 100644 frontend/src/components/ui/icons/snow-flake-icon.tsx create mode 100644 frontend/src/components/ui/icons/solar-panel-icon.tsx create mode 100644 frontend/src/components/ui/icons/trees-icon.tsx create mode 100644 frontend/src/constants/ui-contents/try-fair-contents.ts create mode 100644 frontend/src/enums/try-fair.ts create mode 100644 frontend/src/features/try-fair/components/map/try-fair-map.tsx create mode 100644 frontend/src/features/try-fair/components/model-picker-modal.tsx create mode 100644 frontend/src/features/try-fair/components/try-fair-sidebar.tsx create mode 100644 frontend/src/features/try-fair/models.ts create mode 100644 frontend/src/features/try-fair/utils/constants.tsx diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 3be492468..48f147498 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -31,6 +31,20 @@ const router = createBrowserRouter([ /** * Landing page route ends */ + + /** + * Try fAIr (public demo) route starts. + */ + { + path: APPLICATION_ROUTES.TRY_FAIR, + lazy: async () => { + const { TryFairPage } = await import("@/app/routes/try-fair"); + return { Component: TryFairPage }; + }, + }, + /** + * Try fAIr route ends. + */ { path: APPLICATION_ROUTES.LEARN, lazy: async () => { diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx new file mode 100644 index 000000000..27d2d6750 --- /dev/null +++ b/frontend/src/app/routes/try-fair.tsx @@ -0,0 +1,79 @@ +import { Head } from "@/components/seo"; +import { TRY_FAIR_PAGE_CONTENT } from "@/constants/ui-contents/try-fair-contents"; +import { TryFairMapOutputType, TryFairResolution } from "@/enums/try-fair"; +import { TryFairMap } from "@/features/try-fair/components/map/try-fair-map"; +import { + TryFairSidebar, +} from "@/features/try-fair/components/try-fair-sidebar"; +import { MODELS_LIST, TryFairModel } from "@/features/try-fair/models"; +import { useMapInstance } from "@/hooks/use-map-instance"; +import { useEffect, useState } from "react"; + +export const TryFairPage = () => { + const { map, mapContainerRef } = useMapInstance(false, false); + + const [selectedModel, setSelectedModel] = useState( + MODELS_LIST[0], + ); + const [outputType, setOutputType] = useState( + TryFairMapOutputType.POINTS, + ); + const [resolution, setResolution] = useState( + TryFairResolution.MID, + ); + const [confidence, setConfidence] = useState(30); + + const RESOLUTION_INDEX: Record = { + [TryFairResolution.LOW]: 0, + [TryFairResolution.MID]: 1, + [TryFairResolution.HIGH]: 2, + }; + + // Fly to the zoom level that matches the selected resolution + useEffect(() => { + if (!map) return; + const zoomIndex = RESOLUTION_INDEX[resolution]; + const targetZoom = selectedModel.availableZoomLevels[zoomIndex]; + if (targetZoom !== undefined) { + map.flyTo({ zoom: targetZoom }); + } + }, [resolution, selectedModel, map]); + + // Reset resolution to MID when the model changes + const handleSelectModel = (model: TryFairModel) => { + setSelectedModel(model); + setResolution(TryFairResolution.MID); + }; + + return ( + <> + + +
      +
      + {/* Map fills the entire available space */} + + + {/* Floating sidebar panel, absolutely positioned over the map */} +
      + +
      +
      +
      + + ); +}; diff --git a/frontend/src/components/landing/header/header.tsx b/frontend/src/components/landing/header/header.tsx index 94bc389b2..a65073922 100644 --- a/frontend/src/components/landing/header/header.tsx +++ b/frontend/src/components/landing/header/header.tsx @@ -17,18 +17,20 @@ export const Header = () => {
      - + - diff --git a/frontend/src/components/layouts/navbar/navbar.tsx b/frontend/src/components/layouts/navbar/navbar.tsx index 75679bdba..91b64fe11 100644 --- a/frontend/src/components/layouts/navbar/navbar.tsx +++ b/frontend/src/components/layouts/navbar/navbar.tsx @@ -104,6 +104,7 @@ export const NavBar = () => { /> ) : ( + + {/* Panel — portaled to body so it escapes all overflow clipping */} + {isOpen && + createPortal( +
      + {/* Header */} +
      +

      + What do you want to map +

      + +
      + + {/* 2-column card grid */} +
      + {models.map((model) => { + const isSelected = selectedModel.id === model.id; + return ( + + ); + })} +
      +
      , + document.body, + )} + + ); +}; diff --git a/frontend/src/features/try-fair/components/try-fair-sidebar.tsx b/frontend/src/features/try-fair/components/try-fair-sidebar.tsx new file mode 100644 index 000000000..1a8f56b95 --- /dev/null +++ b/frontend/src/features/try-fair/components/try-fair-sidebar.tsx @@ -0,0 +1,169 @@ +import { TryFairMapOutputType, TryFairResolution } from "@/enums/try-fair"; +import { InfoIcon } from "@/components/ui/icons"; +import { ModelPicker } from "./model-picker-modal"; +import { TRY_FAIR_PAGE_CONTENT } from "@/constants/ui-contents/try-fair-contents"; +import { Button } from "@/components/ui/button"; +import { MapPlayIcon } from "@/components/ui/icons/map-play-icon"; +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 { MODELS_LIST, TryFairModel } from "@/features/try-fair/models"; +import { OUTPUT_TYPES, RESOLUTIONS } from "@/features/try-fair/utils/constants.tsx"; + +type TryFairSidebarProps = { + selectedModel: TryFairModel; + onSelectModel: (model: TryFairModel) => void; + outputType: TryFairMapOutputType; + onOutputTypeChange: (type: TryFairMapOutputType) => void; + resolution: TryFairResolution; + onResolutionChange: (resolution: TryFairResolution) => void; + confidence: number; + onConfidenceChange: (value: number) => void; +}; + + +export const TryFairSidebar = ({ + selectedModel, + onSelectModel, + outputType, + onOutputTypeChange, + resolution, + onResolutionChange, + confidence, + onConfidenceChange, +}: TryFairSidebarProps) => { + + return ( +
      + {/* ── Model selector + Map button ── */} +
      +
      + +
      + + {/* Vertical divider */} +
      + +
      + +
      +
      + + {/* ── Map Output ── */} +
      +

      + {TRY_FAIR_PAGE_CONTENT.sidebar.mapOutput.label} +

      +
      + {OUTPUT_TYPES.map(({ type, label, icon }) => ( + + ))} +
      +
      + + {/* ── Parameters ── */} +
      + {/* Section header */} +
      + +

      + {TRY_FAIR_PAGE_CONTENT.sidebar.parameters.label} +

      +
      + + {/* Description */} +
      + +
      +

      + {TRY_FAIR_PAGE_CONTENT.sidebar.parameters.description}{" "} +

      + e.preventDefault()} + > + {TRY_FAIR_PAGE_CONTENT.sidebar.parameters.learnMore} + +
      +
      + + {/* Resolution */} +
      +

      + {TRY_FAIR_PAGE_CONTENT.sidebar.parameters.resolution.label} +

      +
      + {RESOLUTIONS.map(({ value, label, size }) => ( + + ))} +
      +
      + + {/* Confidence */} +
      +
      +

      + {TRY_FAIR_PAGE_CONTENT.sidebar.parameters.confidence.label} +

      + + {confidence}% + +
      +
      + + onConfidenceChange(Number(e.target.value))} + className="try-fair-confidence-slider flex-1 h-1.5 rounded-full appearance-none cursor-pointer outline-none" + style={{ + background: `linear-gradient(90deg, #0088FF 0%, #FF383C 100%)`, + }} + /> + +
      +
      +
      +
      + ); +}; diff --git a/frontend/src/features/try-fair/models.ts b/frontend/src/features/try-fair/models.ts new file mode 100644 index 000000000..1fd31495d --- /dev/null +++ b/frontend/src/features/try-fair/models.ts @@ -0,0 +1,65 @@ +export type TryFairModel = { + id: number; + feature: string; + location: string; + modelName: string; + author: string; + featureType: string; + tileServiceUrl: string; + bbox: string; + availableZoomLevels: number[] +}; + +export const MODELS_LIST: TryFairModel[] = [ + { + id: 1, + feature: "Buildings", + location: "Freetown", + modelName: "Kolleh Town Freetown", + author: "OmranNAJJAR", + featureType: "building", + tileServiceUrl: + "https://tiles.openaerialmap.org/686e390615a6768f282b22b3/0/686e390615a6768f282b22b4/{z}/{x}/{y}", + bbox: "", + availableZoomLevels: [18, 19, 20] + }, + { + id: 2, + feature: "Solar Panels", + location: "USA", + modelName: "Panels Chicago", + author: "Kshitij Sharma", + featureType: "solar-panel", + tileServiceUrl: + "https://tiles.openaerialmap.org/6a0aa45052774984bedbcfef/0/6a0aa45052774984bedbcff0/{z}/{x}/{y}", + bbox: "", + availableZoomLevels: [18, 19, 20] + + }, + { + id: 3, + feature: "Buildings", + location: "Nepal", + modelName: "Nepal Buildings Model", + author: "Kshitij Sharma", + featureType: "building", + tileServiceUrl: + "https://tiles.openaerialmap.org/66149f1cc055e600014ac54c/0/66149f1cc055e600014ac54d/{z}/{x}/{y}", + bbox: "", + availableZoomLevels: [18, 19, 20] + + }, + { + id: 4, + feature: "Trees", + location: "Ghana", + modelName: "Kolleh Town Freetown", + author: "OmranNAJJAR", + featureType: "trees", + tileServiceUrl: + "https://tiles.openaerialmap.org/6a0b8d586103984552b5f7f2/0/6a0b8d586103984552b5f7f3/{z}/{x}/{y}", + bbox: "", + availableZoomLevels: [19, 20, 21] + + }, +]; diff --git a/frontend/src/features/try-fair/utils/constants.tsx b/frontend/src/features/try-fair/utils/constants.tsx new file mode 100644 index 000000000..0df9bc8f0 --- /dev/null +++ b/frontend/src/features/try-fair/utils/constants.tsx @@ -0,0 +1,46 @@ +import { TRY_FAIR_PAGE_CONTENT } from "@/constants/ui-contents/try-fair-contents"; +import { TryFairMapOutputType, TryFairResolution } from "@/enums/try-fair"; +import { PointsIcon } from "@/components/ui/icons/points-icons"; +import { ClusterIcon } from "@/components/ui/icons/cluster-icon"; +import { PolygonIcon } from "@/components/ui/icons/polygon-icon"; +import React from "react"; + +export const RESOLUTIONS: { value: TryFairResolution; label: string; size: number }[] = [ + { + value: TryFairResolution.LOW, + label: TRY_FAIR_PAGE_CONTENT.sidebar.parameters.resolution.low, + size: 12, + }, + { + value: TryFairResolution.MID, + label: TRY_FAIR_PAGE_CONTENT.sidebar.parameters.resolution.mid, + size: 16, + }, + { + value: TryFairResolution.HIGH, + label: TRY_FAIR_PAGE_CONTENT.sidebar.parameters.resolution.high, + size: 18, + }, +]; + +export const OUTPUT_TYPES: { + type: TryFairMapOutputType; + label: string; + icon: React.ReactNode; +}[] = [ + { + type: TryFairMapOutputType.POINTS, + label: "Points", + icon: , + }, + { + type: TryFairMapOutputType.POLYGON, + label: "Polygon", + icon: , + }, + { + type: TryFairMapOutputType.CLUSTER, + label: "Cluster", + icon: , + }, +]; diff --git a/frontend/src/hooks/use-tileservice.ts b/frontend/src/hooks/use-tileservice.ts index 6403c42a9..b75e3aa96 100644 --- a/frontend/src/hooks/use-tileservice.ts +++ b/frontend/src/hooks/use-tileservice.ts @@ -134,8 +134,14 @@ export const useTileServiceLayer = ({ sourceURL, loading, setLoading, + setTileserverURL, } = useTileservice(getTileServerTypeFromURL(tileServiceURL), tileServiceURL); + // Sync internal state when the URL prop changes (e.g. user switches model) + useEffect(() => { + setTileserverURL(tileServiceURL); + }, [tileServiceURL]); + useEffect(() => { if (!tileServiceTypeValidity.valid || !map || !sourceURL || !addLayerToMap) return; diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index 44bd22953..cf1f634ca 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -437,3 +437,23 @@ sl-alert.success::part(base) { hotosm-auth { --login-btn-border-radius: 0px; } + +/* Try fAIr confidence slider starts */ +.try-fair-confidence-slider::-webkit-slider-thumb { + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: #687075; + cursor: pointer; +} + +.try-fair-confidence-slider::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: #687075; + cursor: pointer; + border: none; +} +/* Try fAIr confidence slider ends */ diff --git a/frontend/src/types/ui-contents.ts b/frontend/src/types/ui-contents.ts index 0dd09d056..4e78e0c83 100644 --- a/frontend/src/types/ui-contents.ts +++ b/frontend/src/types/ui-contents.ts @@ -847,3 +847,48 @@ export type TUserProfilePageContent = { }; // User profile types ends. + + +// Try fAIr page content types starts. + +export type TTryFairPageContent = { + pageTitle: string; + header: { + logoAlt: string; + startMappingButton: string; + }; + sidebar: { + modelSelector: { + placeholder: string; + }; + mapButton: string; + mapButtonRunning: string; + mapOutput: { + label: string; + }; + parameters: { + label: string; + description: string; + learnMore: string; + resolution: { + label: string; + low: string; + mid: string; + high: string; + }; + confidence: { + label: string; + }; + }; + }; + modelPicker: { + title: string; + modelLabel: string; + byLabel: string; + }; + map: { + zoomPrompt: string; + }; +}; + +// Try fAIr page content types ends. \ No newline at end of file From bd3d60efc34de8dc9ab9f2914af3e9176a0d0c55 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 19 May 2026 22:09:01 +0100 Subject: [PATCH 31/62] completed ui conversion --- frontend/src/app/routes/try-fair.tsx | 4 +--- frontend/src/components/landing/header/header.tsx | 2 +- frontend/src/components/layouts/navbar/navbar.tsx | 2 +- frontend/src/components/ui/button/button.tsx | 4 +++- frontend/src/components/ui/icons/buildings-icon.tsx | 2 +- frontend/src/components/ui/icons/flame-icon.tsx | 2 +- frontend/src/components/ui/icons/grid-icon.tsx | 2 +- frontend/src/components/ui/icons/polygon-icon.tsx | 2 +- .../src/components/ui/icons/snow-flake-icon.tsx | 2 +- .../src/components/ui/icons/solar-panel-icon.tsx | 2 +- frontend/src/components/ui/icons/trees-icon.tsx | 2 +- frontend/src/constants/routes.ts | 1 - .../src/constants/ui-contents/try-fair-contents.ts | 3 ++- .../try-fair/components/map/try-fair-map.tsx | 13 ++++++++----- .../try-fair/components/model-picker-modal.tsx | 1 - .../try-fair/components/try-fair-sidebar.tsx | 7 ++++--- frontend/src/features/try-fair/models.ts | 13 +++++-------- frontend/src/features/try-fair/utils/constants.tsx | 6 +++++- frontend/src/types/ui-contents.ts | 3 +-- 19 files changed, 38 insertions(+), 35 deletions(-) diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index 27d2d6750..32a98a99e 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -2,9 +2,7 @@ import { Head } from "@/components/seo"; import { TRY_FAIR_PAGE_CONTENT } from "@/constants/ui-contents/try-fair-contents"; import { TryFairMapOutputType, TryFairResolution } from "@/enums/try-fair"; import { TryFairMap } from "@/features/try-fair/components/map/try-fair-map"; -import { - TryFairSidebar, -} from "@/features/try-fair/components/try-fair-sidebar"; +import { TryFairSidebar } from "@/features/try-fair/components/try-fair-sidebar"; import { MODELS_LIST, TryFairModel } from "@/features/try-fair/models"; import { useMapInstance } from "@/hooks/use-map-instance"; import { useEffect, useState } from "react"; diff --git a/frontend/src/components/landing/header/header.tsx b/frontend/src/components/landing/header/header.tsx index a65073922..4624653e0 100644 --- a/frontend/src/components/landing/header/header.tsx +++ b/frontend/src/components/landing/header/header.tsx @@ -23,7 +23,7 @@ export const Header = () => { > + { /> ) : ( +
      +
      + ); +}; 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 766e239af..8133f0a8c 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 @@ -1,34 +1,27 @@ import { MapComponent } from "@/components/map"; import { useMapStore } from "@/store/map-store"; -// import { Feature } from "@/types"; import { MIN_ZOOM_LEVEL_FOR_START_MAPPING_PREDICTION } from "@/config"; import { Map } from "maplibre-gl"; import { RefObject } from "react"; -import { TryFairMapOutputType, TryFairResolution } from "@/enums/try-fair"; +import { TryFairMapOutputType } from "@/enums/try-fair"; +import { BBOX } from "@/types"; +import { TryFairDraggableGrid } from "./draggable-grid"; type TryFairMapProps = { map: Map | null; mapContainerRef: RefObject; - // predictions: Feature[]; outputType: TryFairMapOutputType; tileServerURL: string; -}; - -export const TRY_FAIR_RESOLUTION_ZOOM_LEVELS: Record< - TryFairResolution, - number -> = { - [TryFairResolution.LOW]: MIN_ZOOM_LEVEL_FOR_START_MAPPING_PREDICTION, - [TryFairResolution.MID]: MIN_ZOOM_LEVEL_FOR_START_MAPPING_PREDICTION + 1, - [TryFairResolution.HIGH]: MIN_ZOOM_LEVEL_FOR_START_MAPPING_PREDICTION + 2, + tileServiceValid: boolean; + onBBoxChange: (bbox: BBOX) => void; }; export const TryFairMap = ({ map, mapContainerRef, - // predictions, - // outputType, tileServerURL, + tileServiceValid, + onBBoxChange, }: TryFairMapProps) => { const zoom = useMapStore((state) => state.zoom); const minZoom = MIN_ZOOM_LEVEL_FOR_START_MAPPING_PREDICTION; @@ -38,15 +31,19 @@ export const TryFairMap = ({ - {/* */} + {map && ( + + )} {zoom < minZoom && (
      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 92414ad94..8c4df4c15 100644 --- a/frontend/src/features/try-fair/components/model-picker-modal.tsx +++ b/frontend/src/features/try-fair/components/model-picker-modal.tsx @@ -113,7 +113,7 @@ export const ModelPicker: React.FC = ({
      -
      - -
      + {!location.pathname.includes(APPLICATION_ROUTES.TRY_FAIR) && ( +
      + +
      + )} {isAuthenticated && }
      @@ -104,7 +109,11 @@ export const NavBar = () => { /> ) : ( )}
      @@ -125,9 +136,11 @@ export const NavBar = () => { className={`${styles.nav} app-padding z-20 py-1 border-b border-gray-border`} > - {/*
      - -
      */} + {!location.pathname.includes(APPLICATION_ROUTES.TRY_FAIR) && ( +
      + +
      + )}
      {AUTH_PROVIDER === "hanko" && !IS_DEV ? ( <> @@ -143,7 +156,11 @@ export const NavBar = () => { ) : ( )} {AUTH_PROVIDER === "hanko" && } diff --git a/frontend/src/constants/ui-contents/shared-content.ts b/frontend/src/constants/ui-contents/shared-content.ts index cbaacb4cf..3cd5e7bc0 100644 --- a/frontend/src/constants/ui-contents/shared-content.ts +++ b/frontend/src/constants/ui-contents/shared-content.ts @@ -46,7 +46,7 @@ export const SHARED_CONTENT: TSharedContent = { jumbotronHeadline: "AI-powered assistant that amplify your mapping efforts intelligently and quickly, helping you map smarter and faster.", ctaPrimaryButton: "Try fAIr", - ctaSecondaryButton: "Login", + ctaSecondaryButton: "Start Mapping", jumbotronImageAlt: "A user engaging in a mapping activity", kpi: { publishedAIModels: "Published AI Models", 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 8133f0a8c..02ceef324 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 @@ -35,6 +35,7 @@ export const TryFairMap = ({ tileServiceURL={tileServiceValid ? tileServerURL : undefined} zoomControls basemaps + showCurrentZoom /> {map && ( From dd7c049d4755476a193dada5df0fe07393fd0450 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Thu, 21 May 2026 09:51:36 +0100 Subject: [PATCH 34/62] fix: stop grid from moving around --- .../try-fair/components/map/draggable-grid.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/try-fair/components/map/draggable-grid.tsx b/frontend/src/features/try-fair/components/map/draggable-grid.tsx index 9a1383de4..ee4b802a0 100644 --- a/frontend/src/features/try-fair/components/map/draggable-grid.tsx +++ b/frontend/src/features/try-fair/components/map/draggable-grid.tsx @@ -121,9 +121,15 @@ export const TryFairDraggableGrid = ({ if (!map || hasDragged) return; const syncToCenter = () => { setAnchor(getCenteredAnchor(map.getCenter())); + // Unsubscribe immediately after the first sync so panning doesn't + // recentre the grid. + map.off("idle", syncToCenter); }; - syncToCenter(); - map.on("idle", syncToCenter); + if (map.isStyleLoaded()) { + syncToCenter(); + } else { + map.once("idle", syncToCenter); + } return () => { map.off("idle", syncToCenter); }; From a5ba570426a9e7b1b7bdfdaa414b923d6f5e358a Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Sun, 24 May 2026 20:13:48 +0100 Subject: [PATCH 35/62] updated draggable grid --- frontend/src/app/routes/try-fair.tsx | 183 +++++-- .../src/components/layouts/navbar/navbar.tsx | 6 +- .../map/controls/fit-to-bounds-control.tsx | 9 +- frontend/src/components/ui/button/button.tsx | 7 +- .../components/map/legend-control.tsx | 6 +- frontend/src/features/try-fair/api/stac.ts | 85 ++++ .../components/map/chloropleth-legend.tsx | 25 + .../components/map/draggable-grid.tsx | 481 ++++++++++++------ .../try-fair/components/map/try-fair-map.tsx | 104 +++- .../map/try-fair-prediction-results.tsx | 162 ++++++ .../components/model-picker-modal.tsx | 111 +++- .../try-fair/components/try-fair-sidebar.tsx | 118 +++-- .../try-fair/hooks/use-base-models.tsx | 27 + .../try-fair/hooks/use-fair-predict.tsx | 62 +++ .../try-fair/hooks/use-try-fair-params.tsx | 48 ++ frontend/src/features/try-fair/models.ts | 60 +++ .../utils/{constants.tsx => common.tsx} | 8 + .../src/features/try-fair/utils/helpers.ts | 248 +++++++++ frontend/src/utils/geo/geometry-utils.ts | 6 +- 19 files changed, 1479 insertions(+), 277 deletions(-) create mode 100644 frontend/src/features/try-fair/api/stac.ts create mode 100644 frontend/src/features/try-fair/components/map/chloropleth-legend.tsx create mode 100644 frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx create mode 100644 frontend/src/features/try-fair/hooks/use-base-models.tsx create mode 100644 frontend/src/features/try-fair/hooks/use-fair-predict.tsx create mode 100644 frontend/src/features/try-fair/hooks/use-try-fair-params.tsx rename frontend/src/features/try-fair/utils/{constants.tsx => common.tsx} (87%) create mode 100644 frontend/src/features/try-fair/utils/helpers.ts diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index 6152f71a3..72a80bd31 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -3,51 +3,131 @@ import { TRY_FAIR_PAGE_CONTENT } from "@/constants/ui-contents/try-fair-contents import { TryFairMapOutputType, TryFairResolution } from "@/enums/try-fair"; import { TryFairMap } from "@/features/try-fair/components/map/try-fair-map"; import { TryFairSidebar } from "@/features/try-fair/components/try-fair-sidebar"; -import { MODELS_LIST, TryFairModel } from "@/features/try-fair/models"; +import { DEMO_MODEL_CONFIGS, getDemoConfig } from "@/features/try-fair/models"; import { useMapInstance } from "@/hooks/use-map-instance"; import { useTileservice } from "@/hooks/use-tileservice"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { getTileServerTypeFromURL } from "@/utils"; +import { useTryFairParams } from "@/features/try-fair/hooks/use-try-fair-params"; +import { useBaseModels } from "@/features/try-fair/hooks/use-base-models"; +import { BBOX } from "@/types"; +import { useFairPredict } from "@/features/try-fair/hooks/use-fair-predict"; +import { getInferenceParams, InferenceParam } from "@/features/try-fair/api/stac"; +import { TRY_FAIR_RESOLUTION_ZOOM } from "@/features/try-fair/utils/common"; export const TryFairPage = () => { const { map, mapContainerRef } = useMapInstance(false, false); - const [selectedModel, setSelectedModel] = useState( - MODELS_LIST[0], + // URL-persisted state (nuqs) + const { + modelId, + outputType, + resolution, + confidence, + setModelId, + setOutputType, + setResolution, + setConfidence, + } = useTryFairParams(); + + // ── Models from STAC + const { models: allModels, loading: modelsLoading } = useBaseModels(); + + const models = useMemo( + () => + DEMO_MODEL_CONFIGS.flatMap((cfg) => + allModels.filter((m) => m.id === cfg.baseModelId), + ), + [allModels], ); - const [outputType, setOutputType] = useState( - TryFairMapOutputType.POINTS, + const selectedModel = useMemo( + () => models.find((m) => m.id === modelId) ?? null, + [models, modelId], ); - const [resolution, setResolution] = useState( - TryFairResolution.MID, + + const demoConfig = useMemo( + () => (selectedModel ? getDemoConfig(selectedModel.id) : undefined), + [selectedModel], ); - const [confidence, setConfidence] = useState(30); + + // ── Inference params (derived from selected model's STAC spec) ──────────── + const inferenceParams: InferenceParam[] = useMemo( + () => (selectedModel ? getInferenceParams(selectedModel) : []), + [selectedModel], + ); + + // Merge STAC defaults with URL-persisted confidence + const paramValues = useMemo(() => { + const values: Record = {}; + inferenceParams.forEach(({ key, spec }) => { + values[key] = key === "confidence_threshold" ? confidence : spec.default; + }); + return values; + }, [inferenceParams, confidence]); + + // Other UI state (not in URL) + const [latestBBox, setLatestBBox] = useState(null); + const [latestGridZoom, setLatestGridZoom] = useState(null); const [isDirty, setIsDirty] = useState(true); + // Tile service + const tileServiceUrl = demoConfig?.tileServiceUrl ?? ""; + const { tileserverURL, setTileserverURL, + loading: tileLoading, tileJSONMetadata, tileServiceTypeValidity, } = useTileservice( - getTileServerTypeFromURL(selectedModel.tileServiceUrl), - selectedModel.tileServiceUrl, + getTileServerTypeFromURL(tileServiceUrl), + tileServiceUrl, ); useEffect(() => { - setTileserverURL(selectedModel.tileServiceUrl); - }, [selectedModel.tileServiceUrl, setTileserverURL]); + setTileserverURL(tileServiceUrl); + }, [tileServiceUrl, setTileserverURL]); + + // Derive the best centre to fly/recenter to: + // 1. tileJSON `center` field 2. bounds midpoint 3. demoConfig fallback + const imageryCenter = useMemo((): [number, number] | undefined => { + if (tileJSONMetadata?.center) { + return [tileJSONMetadata.center[0], tileJSONMetadata.center[1]]; + } + if (tileJSONMetadata?.bounds) { + const b = tileJSONMetadata.bounds as [number, number, number, number]; + return [(b[0] + b[2]) / 2, (b[1] + b[3]) / 2]; + } + return demoConfig?.center; + }, [tileJSONMetadata, demoConfig]); + + // Fly to imagery centre on model load. Resolution controls prediction zoom, + // while the draggable grid now follows the active map zoom level. + const INITIAL_MAP_ZOOM = 18; useEffect(() => { - if (!map) return; - map.flyTo({ center: selectedModel.center, zoom: 18, essential: true }); - map.once("moveend", () => { - if (map.getZoom() !== 18) map.setZoom(18); - }); - }, [map, selectedModel, tileJSONMetadata]); + if (!map || !demoConfig || !imageryCenter) return; - const handleSelectModel = (model: TryFairModel) => { - setSelectedModel(model); + const doFly = () => { + map.flyTo({ center: imageryCenter, zoom: INITIAL_MAP_ZOOM, essential: true }); + }; + + if (map.isStyleLoaded()) { + doFly(); + } else { + map.once("load", doFly); + return () => { map.off("load", doFly); }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map, demoConfig, imageryCenter]); + + + // Predict + const { predict, isPredicting, predictions, predictionBBox, predictionGridZoom } = useFairPredict(); + + // Handlers + const handleSelectModel = (model: { id: string }) => { + setModelId(model.id); setResolution(TryFairResolution.MID); setIsDirty(true); }; @@ -55,50 +135,85 @@ export const TryFairPage = () => { const handleResolutionChange = (res: TryFairResolution) => { setResolution(res); setIsDirty(true); - }; - - const handleConfidenceChange = (value: number) => { - setConfidence(value); - setIsDirty(true); + // Zoom the map to the tile zoom that matches this resolution so the grid + if (map) map.easeTo({ zoom: TRY_FAIR_RESOLUTION_ZOOM[res], essential: true }); }; const handleOutputTypeChange = (type: TryFairMapOutputType) => { setOutputType(type); - setIsDirty(true); + // Output type switch is client side approach - if server side then we will need to re-predict }; - const handleBBoxChange = useCallback(() => { + 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 handleMap = () => { + if (!selectedModel || !latestBBox || !demoConfig) return; + setIsDirty(false); + // Invert confidence_threshold for the API: a lower threshold value produces better results + const apiParams = Object.fromEntries( + Object.entries(paramValues).map(([k, v]) => + k === "confidence_threshold" ? [k, parseFloat((1 - Number(v)).toFixed(2))] : [k, v], + ), + ); + predict({ + model: selectedModel, + localModelUri: demoConfig.localModelUri, + tileServiceUrl: tileserverURL, + bbox: latestBBox, + gridZoom: latestGridZoom ?? undefined, + resolution, + params: apiParams, + }); + }; + return ( <> -
      +
      setIsDirty(false)} - isPredicting={false} - isMapButtonDisabled={!isDirty} + inferenceParams={inferenceParams} + paramValues={paramValues} + onParamChange={handleParamChange} + onMap={handleMap} + isPredicting={isPredicting} + isMapButtonDisabled={!isDirty || !latestBBox || !demoConfig} />
      diff --git a/frontend/src/components/layouts/navbar/navbar.tsx b/frontend/src/components/layouts/navbar/navbar.tsx index ddc0beb53..b13708555 100644 --- a/frontend/src/components/layouts/navbar/navbar.tsx +++ b/frontend/src/components/layouts/navbar/navbar.tsx @@ -1,7 +1,7 @@ import styles from "@/components/layouts/navbar/navbar.module.css"; import { Button } from "@/components/ui/button"; import { Drawer } from "@/components/ui/drawer"; -import { DrawerPlacements } from "@/enums"; +import { ButtonVariant, DrawerPlacements } from "@/enums"; import { HamburgerIcon } from "@/assets/svgs"; import { Image } from "@/components/ui/image"; import { Link } from "@/components/ui/link"; @@ -114,6 +114,8 @@ export const NavBar = () => { ? true : false } + variant={ location.pathname.includes(APPLICATION_ROUTES.TRY_FAIR) ? ButtonVariant.TERTIARY : ButtonVariant.PRIMARY} + onClick={() => { /* * Set the `backgroundLocation` in location state so that when we open the authentication modal we still see the current page in the background. @@ -156,6 +158,8 @@ export const NavBar = () => { ) : ( -
      + +
      ); }; 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 02ceef324..2456c671c 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 @@ -1,41 +1,88 @@ import { MapComponent } from "@/components/map"; -import { useMapStore } from "@/store/map-store"; -import { MIN_ZOOM_LEVEL_FOR_START_MAPPING_PREDICTION } from "@/config"; import { Map } from "maplibre-gl"; -import { RefObject } from "react"; +import { RefObject, useCallback, useRef, useState } from "react"; import { TryFairMapOutputType } from "@/enums/try-fair"; import { BBOX } from "@/types"; -import { TryFairDraggableGrid } from "./draggable-grid"; +import { TryFairDraggableGrid } from "@/features/try-fair/components/map/draggable-grid"; +import { TryFairPredictionsLayer } from "@/features/try-fair/components/map/try-fair-prediction-results"; +import { ChoroplethBucket } from "@/features/try-fair/utils/helpers"; +import { TryFairChoroplethLegend } from "@/features/try-fair/components/map/chloropleth-legend"; +import { LayerControl, FitToBounds, ZoomControls } from "@/components/map/controls"; + + + +// Prediction layer IDs (kept in sync with try-fair-prediction-results.tsx) +const PREDICTION_LAYER_IDS = [ + "try-fair-predictions-fill", + "try-fair-predictions-outline", + "try-fair-predictions-circle", + "try-fair-predictions-cluster", + "try-fair-predictions-cluster-count", + "try-fair-predictions-choropleth-fill", + "try-fair-predictions-choropleth-outline", +]; type TryFairMapProps = { map: Map | null; mapContainerRef: RefObject; outputType: TryFairMapOutputType; tileServerURL: string; + tileLoading: boolean; tileServiceValid: boolean; - onBBoxChange: (bbox: BBOX) => void; + onBBoxChange: (bbox: BBOX, tileZoom: number) => void; + predictions: GeoJSON.FeatureCollection | null; + predictionBBox: BBOX | null; + predictionGridZoom?: number | null; + imageryCenter?: [number, number]; }; export const TryFairMap = ({ map, mapContainerRef, + outputType, tileServerURL, tileServiceValid, onBBoxChange, + predictions, + predictionBBox, + predictionGridZoom, + imageryCenter, }: TryFairMapProps) => { - const zoom = useMapStore((state) => state.zoom); - const minZoom = MIN_ZOOM_LEVEL_FOR_START_MAPPING_PREDICTION; + const [choroplethBuckets, setChoroplethBuckets] = useState< + ChoroplethBucket[] | null + >(null); + // Track the grid bbox locally so fit-to-grid always has the latest value + const gridBBoxRef = useRef(null); + + + + const handleFitToGrid = useCallback(() => { + const bbox = gridBBoxRef.current; + if (!map || !bbox) return; + map.fitBounds( + [bbox[0], bbox[1], bbox[2], bbox[3]], + { padding: 40, essential: true }, + ); + }, [map]); return ( -
      +
      + + {map && ( @@ -43,14 +90,45 @@ export const TryFairMap = ({ map={map} mapContainerRef={mapContainerRef} onBBoxChange={onBBoxChange} + center={imageryCenter} /> )} - {zoom < minZoom && ( -
      - Zoom in to at least zoom {minZoom} to run predictions. + {/* ── Right-side control strip ──────────────────────────────────── */} + {map && ( +
      + {/* Zoom in / out */} + + + {/* Divider */} +
      + + {/* Zoom to grid */} + + + {/* Layers panel */} +
      )} + + {outputType === TryFairMapOutputType.CLUSTER && ( + + )}
      ); }; diff --git a/frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx b/frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx new file mode 100644 index 000000000..9746c186e --- /dev/null +++ b/frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx @@ -0,0 +1,162 @@ +import { TryFairMapOutputType } from "@/enums/try-fair"; +import { BBOX } from "@/types"; +import { ExpressionSpecification, Map } from "maplibre-gl"; +import { useEffect, useMemo } from "react"; +import { + buildChoropleth, + computeChoroplethBuckets, + toPointCollection, +} from "@/features/try-fair/utils/helpers"; + +// ── Layer / source IDs + +const SOURCE_ID = "try-fair-predictions"; +const FILL_LAYER = "try-fair-predictions-fill"; +const OUTLINE_LAYER = "try-fair-predictions-outline"; +const CIRCLE_LAYER = "try-fair-predictions-circle"; +const CLUSTER_LAYER = "try-fair-predictions-cluster"; +const CLUSTER_COUNT_LAYER = "try-fair-predictions-cluster-count"; +const CHOROPLETH_FILL_LAYER = "try-fair-predictions-choropleth-fill"; +const CHOROPLETH_OUTLINE_LAYER = "try-fair-predictions-choropleth-outline"; + +const ALL_LAYER_IDS = [ + FILL_LAYER, + OUTLINE_LAYER, + CIRCLE_LAYER, + CLUSTER_LAYER, + CLUSTER_COUNT_LAYER, + CHOROPLETH_FILL_LAYER, + CHOROPLETH_OUTLINE_LAYER, +]; + + +const removeLayers = (map: Map) => { + ALL_LAYER_IDS.forEach((id) => { + if (map.getLayer(id)) map.removeLayer(id); + }); + if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID); +}; + +// ── Component ───────────────────────────────────────────────────────────────── + +type Props = { + map: Map | null; + predictions: GeoJSON.FeatureCollection | null; + predictionBBox: BBOX | null; + predictionGridZoom?: number; + outputType: TryFairMapOutputType; + onChoroplethBucketsChange?: ( + buckets: ReturnType | null, + ) => void; +}; + +export const TryFairPredictionsLayer = ({ + map, + predictions, + predictionBBox, + predictionGridZoom, + outputType, + onChoroplethBucketsChange, +}: Props) => { + const { choropleth, buckets } = useMemo(() => { + if ( + outputType !== TryFairMapOutputType.CLUSTER || + !predictions || + !predictionBBox + ) { + return { choropleth: null, buckets: null }; + } + const fc = buildChoropleth(predictions, predictionBBox, predictionGridZoom ?? undefined); + return { choropleth: fc, buckets: computeChoroplethBuckets(fc) }; + }, [outputType, predictions, predictionBBox, predictionGridZoom]); + + // Push buckets up to the parent for the legend + useEffect(() => { + onChoroplethBucketsChange?.(buckets); + }, [buckets, onChoroplethBucketsChange]); + + useEffect(() => { + if (!map || !map.getStyle()) return; + removeLayers(map); + if (!predictions || !predictions.features.length) return; + + // ── Polygon ────────────────────────────────────────────────────────────── + if (outputType === TryFairMapOutputType.POLYGON) { + map.addSource(SOURCE_ID, { type: "geojson", data: predictions }); + map.addLayer({ + id: FILL_LAYER, + type: "fill", + source: SOURCE_ID, + paint: { "fill-color": "#A243DC", "fill-opacity": 0.45 }, + }); + map.addLayer({ + id: OUTLINE_LAYER, + type: "line", + source: SOURCE_ID, + paint: { "line-color": "#A243DC", "line-width": 1.5 }, + }); + + // ── Points ─────────────────────────────────────────────────────────────── + } else if (outputType === TryFairMapOutputType.POINTS) { + map.addSource(SOURCE_ID, { + type: "geojson", + data: toPointCollection(predictions), + }); + map.addLayer({ + id: CIRCLE_LAYER, + type: "circle", + source: SOURCE_ID, + paint: { + "circle-radius": 4, + "circle-color": "#A147D8", + "circle-stroke-color": "#A147D8", + "circle-stroke-width": 1, + }, + }); + + // ── Choropleth ─────────────────────────────────────────────────────────── + } else if (outputType === TryFairMapOutputType.CLUSTER) { + if (!choropleth || !buckets) return; + + map.addSource(SOURCE_ID, { type: "geojson", data: choropleth }); + + // Build a dynamic step expression: stops are each bucket's `min` value + // paired with that bucket's colour. Values below the first stop fall + // through to the default (transparent — i.e. cells with 0 predictions). + const stops = buckets.flatMap((b) => [b.min, b.color]); + + map.addLayer({ + id: CHOROPLETH_FILL_LAYER, + type: "fill", + source: SOURCE_ID, + paint: { + "fill-color": [ + "step", + ["get", "count"], + "rgba(0,0,0,0)", + ...stops, + ] as ExpressionSpecification, + "fill-opacity": 0.75, + }, + }); + + // Subtle grid outline so cells are visible even on empty ones + map.addLayer({ + id: CHOROPLETH_OUTLINE_LAYER, + type: "line", + source: SOURCE_ID, + paint: { + "line-color": "#7C3AED", + "line-width": 0.5, + "line-opacity": 0.4, + }, + }); + } + + return () => { + if (map.getStyle()) removeLayers(map); + }; + }, [map, predictions, predictionBBox, outputType, choropleth, buckets]); + + return null; +}; 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 8c4df4c15..bfd663bc8 100644 --- a/frontend/src/features/try-fair/components/model-picker-modal.tsx +++ b/frontend/src/features/try-fair/components/model-picker-modal.tsx @@ -1,30 +1,41 @@ import { useRef, useState, useEffect, useCallback } from "react"; import { createPortal } from "react-dom"; import { ChevronDownIcon, CloseIcon } from "@/components/ui/icons"; -import { TryFairModel } from "@/features/try-fair/models"; -import { BuildingIcon } from "@/components/ui/icons/buildings-icon"; -import { TreesIcon } from "@/components/ui/icons/trees-icon"; -import { SolarPanelIcon } from "@/components/ui/icons/solar-panel-icon"; +import { BaseModelStacItem } from "@/features/try-fair/api/stac"; type ModelPickerProps = { - selectedModel: TryFairModel; - onSelect: (model: TryFairModel) => void; - models: TryFairModel[]; + selectedModel: BaseModelStacItem | null; + onSelect: (model: BaseModelStacItem) => void; + models: BaseModelStacItem[]; + loading?: boolean; + }; -const FeatureBadge = ({ label, type }: { label: string; type: string }) => ( - - {type === "building" && } - {type === "trees" && } - {type === "solar-panel" && } - {label} - -); +// const FeatureBadge = ({ label, type }: { label: string; type: string }) => ( +// +// {type === "building" && } +// {type === "trees" && } +// {type === "solar-panel" && } +// {label} +// +// ); + +const FeatureBadge = ({ label }: { label: string }) => { + const featureLabel = label + .replace(/-/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); + return ( + + {featureLabel} + + ); +}; export const ModelPicker: React.FC = ({ selectedModel, onSelect, models, + loading = false, }) => { const [isOpen, setIsOpen] = useState(false); const [panelStyle, setPanelStyle] = useState({}); @@ -70,7 +81,7 @@ export const ModelPicker: React.FC = ({ return () => document.removeEventListener("mousedown", handler); }, [isOpen]); - const handleSelect = (model: TryFairModel) => { +const handleSelect = (model: BaseModelStacItem) => { onSelect(model); setIsOpen(false); }; @@ -84,13 +95,21 @@ export const ModelPicker: React.FC = ({ onClick={() => setIsOpen((v) => !v)} className="flex h-[40px] justify-between gap-2 items-center w-full min-w-0" > -
      -

      - {selectedModel.feature} -

      -

      - {selectedModel.location} -

      +
      + {loading ? ( +

      Loading models…

      + ) : selectedModel ? ( + <> +

      + {selectedModel.properties["mlm:architecture"]} +

      +

      + {selectedModel.properties.title} +

      + + ) : ( +

      Select a model

      + )}
      = ({ {/* 2-column card grid */}
      - {models.map((model) => { - const isSelected = selectedModel.id === model.id; + {models.map((model) => { + const isSelected = selectedModel?.id === model.id; + const tasks = model.properties["mlm:tasks"] ?? []; + + return ( + + ); + })} + {/* {models.map((model) => { + const isSelected = selectedModel?.id === model.id; + const tasks = model.properties["mlm:tasks"] ?? []; return ( ); - })} + })} */}
      , document.body, 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 74c656b81..1e530b83e 100644 --- a/frontend/src/features/try-fair/components/try-fair-sidebar.tsx +++ b/frontend/src/features/try-fair/components/try-fair-sidebar.tsx @@ -8,21 +8,24 @@ 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 { MODELS_LIST, TryFairModel } from "@/features/try-fair/models"; import { OUTPUT_TYPES, RESOLUTIONS, -} from "@/features/try-fair/utils/constants.tsx"; +} from "@/features/try-fair/utils/common"; +import { BaseModelStacItem, InferenceParam } from "@/features/try-fair/api/stac"; type TryFairSidebarProps = { - selectedModel: TryFairModel; - onSelectModel: (model: TryFairModel) => void; + selectedModel: BaseModelStacItem | null; + models: BaseModelStacItem[]; + modelsLoading: boolean; + onSelectModel: (model: BaseModelStacItem) => void; outputType: TryFairMapOutputType; onOutputTypeChange: (type: TryFairMapOutputType) => void; resolution: TryFairResolution; onResolutionChange: (resolution: TryFairResolution) => void; - confidence: number; - onConfidenceChange: (value: number) => void; + inferenceParams: InferenceParam[]; + paramValues: Record; + onParamChange: (key: string, value: number | string | boolean) => void; onMap: () => void; isPredicting: boolean; isMapButtonDisabled: boolean; @@ -30,13 +33,16 @@ type TryFairSidebarProps = { export const TryFairSidebar = ({ selectedModel, + models, + modelsLoading, onSelectModel, outputType, onOutputTypeChange, resolution, onResolutionChange, - confidence, - onConfidenceChange, + inferenceParams, + paramValues, + onParamChange, onMap, isPredicting, isMapButtonDisabled, @@ -49,7 +55,8 @@ export const TryFairSidebar = ({
      @@ -60,10 +67,12 @@ export const TryFairSidebar = ({ @@ -113,13 +121,13 @@ export const TryFairSidebar = ({

      {TRY_FAIR_PAGE_CONTENT.sidebar.parameters.description}{" "}

      - e.preventDefault()} > {TRY_FAIR_PAGE_CONTENT.sidebar.parameters.learnMore} - + */}
      @@ -128,25 +136,76 @@ export const TryFairSidebar = ({

      {TRY_FAIR_PAGE_CONTENT.sidebar.parameters.resolution.label}

      +
      {RESOLUTIONS.map(({ value, label, size }) => ( ))}
      +
      + + + {/* Confidence is the only inference param exposed in the UI. + Other params (iou_threshold, min_class_value, …) stay at their + STAC defaults and are forwarded to the predict call */} + {inferenceParams + .filter((p) => p.key === "confidence_threshold") + .map(({ key, spec }) => { + const value = paramValues[key] ?? spec.default; + const min = spec.min ?? 0; + const max = spec.max ?? 1; + + return ( +
      +
      +

      Confidence

      + + {Math.round(Number(value) * 100)}% + +
      + +
      + + + + onParamChange(key, parseFloat(e.target.value))} + + className="try-fair-confidence-slider flex-1 h-1.5 rounded-full appearance-none cursor-pointer outline-none" + style={{ + background: `linear-gradient(90deg, #0088FF 0%, #FF383C 100%)`, + }} + /> + + +
      +
      + ); + })} +
      +
      + ); +}; + + {/* Confidence */} -
      + {/*

      {TRY_FAIR_PAGE_CONTENT.sidebar.parameters.confidence.label} @@ -159,11 +218,12 @@ export const TryFairSidebar = ({ onConfidenceChange(Number(e.target.value))} + min={min} + max={max} + step={0.01} + value={Number(value)} + onChange={(e) => onParamChange(key, parseFloat(e.target.value))} + className="try-fair-confidence-slider flex-1 h-1.5 rounded-full appearance-none cursor-pointer outline-none" style={{ background: `linear-gradient(90deg, #0088FF 0%, #FF383C 100%)`, @@ -171,8 +231,4 @@ export const TryFairSidebar = ({ />

      -
      -
      -
      - ); -}; +
      */} diff --git a/frontend/src/features/try-fair/hooks/use-base-models.tsx b/frontend/src/features/try-fair/hooks/use-base-models.tsx new file mode 100644 index 000000000..447107334 --- /dev/null +++ b/frontend/src/features/try-fair/hooks/use-base-models.tsx @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query"; +import { getBaseModels } from "@/features/base-models/api/get-base-models"; +import { BaseModelStacItem } from "@/features/try-fair/api/stac"; + +/** + * Fetches all non-deprecated base models from the STAC catalogue. + * Reuses the existing base-models API function but returns the raw STAC items + * (not mapped to TBaseModel) so the try-fair page can access inference fields + * such as mlm:inference-endpoint, mlm:hyperparameters, etc. + */ +export const useBaseModels = () => { + const { data, isLoading, error } = useQuery({ + queryKey: ["try-fair-base-models"], + queryFn: async () => { + const res = await getBaseModels({ limit: 100 }); + return (res.features as BaseModelStacItem[]).filter( + (f) => !f.properties.deprecated, + ); + }, + }); + + return { + models: data ?? [], + loading: isLoading, + error: error instanceof Error ? error.message : null, + }; +}; diff --git a/frontend/src/features/try-fair/hooks/use-fair-predict.tsx b/frontend/src/features/try-fair/hooks/use-fair-predict.tsx new file mode 100644 index 000000000..a6f52492f --- /dev/null +++ b/frontend/src/features/try-fair/hooks/use-fair-predict.tsx @@ -0,0 +1,62 @@ +import { useMutation } from "@tanstack/react-query"; +import { BaseModelStacItem, runPredict } from "../api/stac"; +import { TryFairResolution } from "@/enums/try-fair"; +import { BBOX } from "@/types"; +import { TRY_FAIR_RESOLUTION_ZOOM } from "@/features/try-fair/utils/common"; +type PredictResult = { + predictions: GeoJSON.FeatureCollection; + bbox: BBOX; + gridZoom: number | undefined; +}; + +type PredictArgs = { + /** Base model — provides the inference endpoint URL */ + model: BaseModelStacItem; + localModelUri: string; + tileServiceUrl: string; + bbox: BBOX; + /** Exact tile zoom used to build the bbox from draggable grid tile boundaries. */ + gridZoom?: number; + resolution: TryFairResolution; + params: Record; +}; + + + +export const useFairPredict = () => { + const { mutate, isPending, data, error, reset } = useMutation< + PredictResult, + Error, + PredictArgs + >({ + mutationFn: async ({ + model, + localModelUri, + tileServiceUrl, + bbox, + gridZoom, + resolution, + params, + }) => { + const inferenceEndpoint = model.assets["mlm:inference-endpoint"].href; + const predictions = await runPredict(inferenceEndpoint, { + model_uri: localModelUri, + image_uri: tileServiceUrl, + bbox, + zoom: gridZoom ?? TRY_FAIR_RESOLUTION_ZOOM[resolution], + params, + }); + return { predictions, bbox, gridZoom }; + }, + }); + + return { + predict: mutate, + isPredicting: isPending, + predictions: data?.predictions ?? null, + predictionBBox: data?.bbox ?? null, + predictionGridZoom: data?.gridZoom ?? null, + error: error?.message ?? null, + clearPredictions: reset, + }; +}; 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 new file mode 100644 index 000000000..079e973e4 --- /dev/null +++ b/frontend/src/features/try-fair/hooks/use-try-fair-params.tsx @@ -0,0 +1,48 @@ +import { TryFairMapOutputType, TryFairResolution } from "@/enums/try-fair"; +import { parseAsFloat, parseAsString, useQueryStates } from "nuqs"; + +const VALID_OUTPUTS = Object.values(TryFairMapOutputType) as string[]; +const VALID_RESOLUTIONS = Object.values(TryFairResolution) as string[]; + +/** + * Persists the Try fAIr sidebar UI state in URL search params via nuqs. + * + * Params: + * model — base model ID, e.g. "unet-segmentation" + * output — visualization type: "polygon" | "points" | "cluster" + * resolution — zoom resolution: "low" | "mid" | "high" + * confidence — confidence threshold (0–1), e.g. 0.5 + */ +export const useTryFairParams = () => { + const [params, setParams] = useQueryStates( + { + model: parseAsString.withDefault("unet-segmentation"), + output: parseAsString.withDefault(TryFairMapOutputType.POINTS), + resolution: parseAsString.withDefault(TryFairResolution.LOW), + confidence: parseAsFloat.withDefault(0.5), + }, + { history: "replace" } + ); + + const outputType = VALID_OUTPUTS.includes(params.output) + ? (params.output as TryFairMapOutputType) + : TryFairMapOutputType.POINTS; + + const resolution = VALID_RESOLUTIONS.includes(params.resolution) + ? (params.resolution as TryFairResolution) + : TryFairResolution.LOW; + + return { + modelId: params.model, + outputType, + resolution, + confidence: params.confidence, + + setModelId: (id: string) => setParams({ model: id }), + setOutputType: (type: TryFairMapOutputType) => + setParams({ output: type }), + setResolution: (res: TryFairResolution) => + setParams({ resolution: res }), + setConfidence: (val: number) => setParams({ confidence: val }), + }; +}; diff --git a/frontend/src/features/try-fair/models.ts b/frontend/src/features/try-fair/models.ts index ce93387a7..fe3b0a118 100644 --- a/frontend/src/features/try-fair/models.ts +++ b/frontend/src/features/try-fair/models.ts @@ -75,3 +75,63 @@ export const MODELS_LIST: TryFairModel[] = [ center: [-71.656, -40.767], }, ]; + + + +export type DemoModelConfig = { + baseModelId: string; + localModelUri: string; + tileServiceUrl: string; + center: [number, number]; + displayName: string; + location: string; + featureType: string; + author: string; +}; + +export const DEMO_MODEL_CONFIGS: DemoModelConfig[] = [ + { + // UNet segmentation — best polygons, shown first as the default + baseModelId: "unet-segmentation", + localModelUri: + "https://s3.fair.krschap.tech/zenml/local-models/08e20666-f8fa-4b8a-8fe8-72661a590fd0/model/model.onnx", + tileServiceUrl: + "https://tiles.openaerialmap.org/62d85d11d8499800053796c1/0/62d85d11d8499800053796c2/{z}/{x}/{y}", + center: [85.5228, 27.6337], + displayName: "Buildings", + location: "Freetown", + featureType: "building", + author: "HOTOSM", + }, + { + // ResNet18 classification + baseModelId: "resnet18-classification", + localModelUri: + "https://s3.fair.krschap.tech/zenml/local-models/27f6f5a6-d079-44ac-80b1-be9ff268c2cb/model/model.onnx", + tileServiceUrl: + "https://tiles.openaerialmap.org/62d85d11d8499800053796c1/0/62d85d11d8499800053796c2/{z}/{x}/{y}", + center: [85.5228, 27.6337], + displayName: "Buildings", + location: "Kathmandu", + featureType: "building", + author: "HOTOSM", + }, + { + // YOLO11n detection + baseModelId: "yolo11n-detection", + localModelUri: + "https://s3.fair.krschap.tech/zenml/local-models/1e398477-2472-46ea-9286-cd89411e1c32/model/model.onnx", + tileServiceUrl: + "https://tiles.openaerialmap.org/62d85d11d8499800053796c1/0/62d85d11d8499800053796c2/{z}/{x}/{y}", + center: [85.5228, 27.6337], + displayName: "Buildings", + location: "Nepal", + featureType: "building", + author: "HOTOSM", + }, +]; + +export const getDemoConfig = ( + baseModelId: string, +): DemoModelConfig | undefined => + DEMO_MODEL_CONFIGS.find((c) => c.baseModelId === baseModelId); diff --git a/frontend/src/features/try-fair/utils/constants.tsx b/frontend/src/features/try-fair/utils/common.tsx similarity index 87% rename from frontend/src/features/try-fair/utils/constants.tsx rename to frontend/src/features/try-fair/utils/common.tsx index 45bd9dacd..5211e4f05 100644 --- a/frontend/src/features/try-fair/utils/constants.tsx +++ b/frontend/src/features/try-fair/utils/common.tsx @@ -48,3 +48,11 @@ export const OUTPUT_TYPES: { icon: , }, ]; + + + +export const TRY_FAIR_RESOLUTION_ZOOM: Record = { + [TryFairResolution.LOW]: 17, + [TryFairResolution.MID]: 18, + [TryFairResolution.HIGH]: 19, +}; diff --git a/frontend/src/features/try-fair/utils/helpers.ts b/frontend/src/features/try-fair/utils/helpers.ts new file mode 100644 index 000000000..8fedabd60 --- /dev/null +++ b/frontend/src/features/try-fair/utils/helpers.ts @@ -0,0 +1,248 @@ + +// ── Geometry helpers ────────────────────────────────────────────────────────── + +import { BBOX } from "@/types"; +import { deg2num, num2deg } from "@/utils/geo/geometry-utils"; + +/** + * Returns the centroid of a single exterior ring (array of [lon, lat] positions). + */ +export const ringCentroid = (ring: number[][]): [number, number] => { + const sumX = ring.reduce((s, c) => s + c[0], 0); + const sumY = ring.reduce((s, c) => s + c[1], 0); + return [sumX / ring.length, sumY / ring.length]; +}; + + +/** + * Returns exactly ONE centroid per feature regardless of geometry type. + * - Point → the coordinate itself + * - Polygon → centroid of the exterior ring + * - MultiPolygon → centroid computed across ALL sub-polygon exterior rings + * (one representative point for the whole shape) + */ +export const featureCentroid = (feature: GeoJSON.Feature): [number, number] | null => { + const geom = feature.geometry; + if (geom.type === "Point") return geom.coordinates as [number, number]; + if (geom.type === "Polygon") { + const ring = geom.coordinates[0]; + return ring.length ? ringCentroid(ring) : null; + } + if (geom.type === "MultiPolygon") { + // Flatten exterior rings of all sub-polygons into one pool of coordinates + const allCoords = geom.coordinates.flatMap((poly) => poly[0]); + return allCoords.length ? ringCentroid(allCoords) : null; + } + return null; +}; + +export const toPointCollection = ( + fc: GeoJSON.FeatureCollection, +): GeoJSON.FeatureCollection => ({ + type: "FeatureCollection", + features: fc.features.flatMap((f) => { + const coords = featureCentroid(f); + if (!coords) return []; + return [{ + type: "Feature" as const, + geometry: { type: "Point" as const, coordinates: coords }, + properties: f.properties, + }]; + }), +}); + +export const CHOROPLETH_GRID_COLS = 5; +export const CHOROPLETH_GRID_ROWS = 5; + +/** Lavender → deep purple ramp (5 buckets, matches design) */ +export const CHOROPLETH_COLORS = [ + "#EDE9FE", + "#C4B5FD", + "#8B5CF6", + "#6D28D9", + "#3B0764", +] as const; + +export type ChoroplethBucket = { + min: number; + max: number; + color: string; + label: string; +}; + + +// ── Choropleth grid spec (mirrors draggable-grid.tsx — must stay in sync) ─── + +type GridSpec = { columns: number; rows: number }; +const DEFAULT_GRID_SPEC: GridSpec = { columns: 2, rows: 2 }; +const GRID_SPEC_BY_ZOOM: Record = { + 17: { columns: 3, rows: 3 }, + 18: { columns: 2, rows: 2 }, + 19: { columns: 3, rows: 3 }, + 20: { columns: 3, rows: 3 }, +}; +const getGridSpec = (zoom: number): GridSpec => + GRID_SPEC_BY_ZOOM[zoom] ?? DEFAULT_GRID_SPEC; + +/** + * Divides `bbox` into a grid and counts how many prediction feature centroids + * fall in each cell. + * + * When `gridZoom` is provided the cells are tile-aligned (using the same + * num2deg / deg2num math as the visual draggable grid), so the choropleth + * fills overlay exactly on top of the red grid. Without `gridZoom` it falls + * back to a simple 5×5 equal-degree division. + */ +export const buildChoropleth = ( + predictions: GeoJSON.FeatureCollection, + bbox: BBOX, + gridZoom?: number, +): GeoJSON.FeatureCollection => { + if (gridZoom !== undefined) { + return buildTileAlignedChoropleth(predictions, bbox, gridZoom); + } + return buildEqualDegreeChoropleth(predictions, bbox); +}; + +// ── Tile-aligned (primary path) ─────────────────────────────────────────────── + +const buildTileAlignedChoropleth = ( + predictions: GeoJSON.FeatureCollection, + bbox: BBOX, + gridZoom: number, +): GeoJSON.FeatureCollection => { + // Recover the integer anchor tile from the bbox NW corner. + // The bbox was computed with num2deg so deg2num should give very nearly + // integer values — Math.round cleans up any floating-point drift. + const [west, , , north] = bbox; + const { xtile, ytile } = deg2num(north, west, gridZoom); + const anchorX = Math.round(xtile); + const anchorY = Math.round(ytile); + const { columns: numCols, rows: numRows } = getGridSpec(gridZoom); + + // Count predictions per tile cell + const counts: number[][] = Array.from({ length: numRows }, () => + Array(numCols).fill(0), + ); + for (const feature of predictions.features) { + const centroid = featureCentroid(feature); + if (!centroid) continue; + const [cx, cy] = centroid; + const { xtile: tx, ytile: ty } = deg2num(cy, cx, gridZoom); + const col = Math.floor(tx - anchorX); + const row = Math.floor(ty - anchorY); + if (col >= 0 && col < numCols && row >= 0 && row < numRows) { + counts[row][col]++; + } + } + + // Build one polygon per tile using exact tile-corner coordinates + const features: GeoJSON.Feature[] = []; + for (let r = 0; r < numRows; r++) { + for (let c = 0; c < numCols; c++) { + const { lon_deg: w, lat_deg: n } = num2deg(anchorX + c, anchorY + r, gridZoom); + const { lon_deg: e, lat_deg: s } = num2deg(anchorX + c + 1, anchorY + r + 1, gridZoom); + features.push({ + type: "Feature", + geometry: { + type: "Polygon", + coordinates: [[[w, s], [e, s], [e, n], [w, n], [w, s]]], + }, + properties: { count: counts[r][c] }, + }); + } + } + return { type: "FeatureCollection", features }; +}; + +// ── Equal-degree fallback (kept for non-tile-zoom contexts) ─────────────────── + +const buildEqualDegreeChoropleth = ( + predictions: GeoJSON.FeatureCollection, + bbox: BBOX, +): GeoJSON.FeatureCollection => { + const [west, south, east, north] = bbox; + const cellW = (east - west) / CHOROPLETH_GRID_COLS; + const cellH = (north - south) / CHOROPLETH_GRID_ROWS; + + const counts: number[][] = Array.from({ length: CHOROPLETH_GRID_ROWS }, () => + Array(CHOROPLETH_GRID_COLS).fill(0), + ); + + for (const feature of predictions.features) { + const centroid = featureCentroid(feature); + if (!centroid) continue; + const [cx, cy] = centroid; + const col = Math.min( + Math.floor((cx - west) / cellW), + CHOROPLETH_GRID_COLS - 1, + ); + const row = Math.min( + Math.floor((cy - south) / cellH), + CHOROPLETH_GRID_ROWS - 1, + ); + if (col >= 0 && col < CHOROPLETH_GRID_COLS && row >= 0 && row < CHOROPLETH_GRID_ROWS) { + counts[row][col]++; + } + } + + const features: GeoJSON.Feature[] = []; + for (let r = 0; r < CHOROPLETH_GRID_ROWS; r++) { + for (let c = 0; c < CHOROPLETH_GRID_COLS; c++) { + const w = west + c * cellW; + const e = west + (c + 1) * cellW; + const s = south + r * cellH; + const n = south + (r + 1) * cellH; + features.push({ + type: "Feature", + geometry: { + type: "Polygon", + coordinates: [[[w, s], [e, s], [e, n], [w, n], [w, s]]], + }, + properties: { count: counts[r][c] }, + }); + } + } + + return { type: "FeatureCollection", features }; +}; + +/** + * Computes dynamic legend buckets from the choropleth cells. The range is + * split into 5 equal-width buckets based on the maximum cell count, so the + * legend always reflects the actual prediction values. + */ +export const computeChoroplethBuckets = ( + choropleth: GeoJSON.FeatureCollection, +): ChoroplethBucket[] => { + let maxCount = 0; + for (const f of choropleth.features) { + const c = (f.properties?.count as number) ?? 0; + if (c > maxCount) maxCount = c; + } + + if (maxCount <= 0) { + // No predictions — fall back to a stable [1, 2, 3, 4, 5+] scale so the + // legend still renders something readable. + return CHOROPLETH_COLORS.map((color, i) => ({ + min: i + 1, + max: i === CHOROPLETH_COLORS.length - 1 ? Infinity : i + 1, + color, + label: + i === CHOROPLETH_COLORS.length - 1 ? `${i + 1}+` : `${i + 1}`, + })); + } + + const step = Math.max(1, Math.ceil(maxCount / CHOROPLETH_COLORS.length)); + return CHOROPLETH_COLORS.map((color, i) => { + const min = i * step + 1; + const max = i === CHOROPLETH_COLORS.length - 1 ? Infinity : (i + 1) * step; + const label = + max === Infinity + ? `${min}+` + : min === max + ? `${min}` + : `${min}–${max}`; // en-dash + return { min, max, color, label }; + }); +}; \ No newline at end of file diff --git a/frontend/src/utils/geo/geometry-utils.ts b/frontend/src/utils/geo/geometry-utils.ts index 40fc7847c..3ac2ca267 100644 --- a/frontend/src/utils/geo/geometry-utils.ts +++ b/frontend/src/utils/geo/geometry-utils.ts @@ -95,7 +95,7 @@ const degrees_to_radians = (degrees: number): number => { * - `xtile` {number}: The tile number on the x-axis. * - `ytile` {number}: The tile number on the y-axis. */ -const deg2num = ( +export const deg2num = ( lat_deg: number, lon_deg: number, zoom: number, @@ -119,7 +119,7 @@ const deg2num = ( * * @returns {number} The angle in degress. */ -const radians_to_degrees = (radians: number): number => { +export const radians_to_degrees = (radians: number): number => { const pi = Math.PI; return radians * (180 / pi); }; @@ -135,7 +135,7 @@ const radians_to_degrees = (radians: number): number => { * - `lat_deg` {number}: The latitude in degrees. * - `lon_deg` {number}: The longitude in degrees. */ -const num2deg = ( +export const num2deg = ( xtile: number, ytile: number, zoom: number, From d80730dc9b784e53088d618949206720b2ffe6e7 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Sun, 24 May 2026 20:15:22 +0100 Subject: [PATCH 36/62] updated draggable grid --- frontend/src/app/routes/try-fair.tsx | 42 ++++++---- .../src/components/layouts/navbar/navbar.tsx | 14 +++- frontend/src/components/ui/button/button.tsx | 4 +- frontend/src/features/try-fair/api/stac.ts | 9 ++- .../components/map/draggable-grid.tsx | 76 ++++++++++++++----- .../try-fair/components/map/try-fair-map.tsx | 18 ++--- .../map/try-fair-prediction-results.tsx | 13 ++-- .../components/model-picker-modal.tsx | 20 +++-- .../try-fair/components/try-fair-sidebar.tsx | 43 ++++++----- .../try-fair/hooks/use-fair-predict.tsx | 2 - .../try-fair/hooks/use-try-fair-params.tsx | 8 +- frontend/src/features/try-fair/models.ts | 2 - .../src/features/try-fair/utils/common.tsx | 2 - .../src/features/try-fair/utils/helpers.ts | 69 +++++++++++------ 14 files changed, 199 insertions(+), 123 deletions(-) diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index 72a80bd31..7f160e3f6 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -12,7 +12,10 @@ import { useTryFairParams } from "@/features/try-fair/hooks/use-try-fair-params" import { useBaseModels } from "@/features/try-fair/hooks/use-base-models"; import { BBOX } from "@/types"; import { useFairPredict } from "@/features/try-fair/hooks/use-fair-predict"; -import { getInferenceParams, InferenceParam } from "@/features/try-fair/api/stac"; +import { + getInferenceParams, + InferenceParam, +} from "@/features/try-fair/api/stac"; import { TRY_FAIR_RESOLUTION_ZOOM } from "@/features/try-fair/utils/common"; export const TryFairPage = () => { @@ -79,10 +82,7 @@ export const TryFairPage = () => { loading: tileLoading, tileJSONMetadata, tileServiceTypeValidity, - } = useTileservice( - getTileServerTypeFromURL(tileServiceUrl), - tileServiceUrl, - ); + } = useTileservice(getTileServerTypeFromURL(tileServiceUrl), tileServiceUrl); useEffect(() => { setTileserverURL(tileServiceUrl); @@ -109,23 +109,34 @@ export const TryFairPage = () => { if (!map || !demoConfig || !imageryCenter) return; const doFly = () => { - map.flyTo({ center: imageryCenter, zoom: INITIAL_MAP_ZOOM, essential: true }); + map.flyTo({ + center: imageryCenter, + zoom: INITIAL_MAP_ZOOM, + essential: true, + }); }; if (map.isStyleLoaded()) { doFly(); } else { map.once("load", doFly); - return () => { map.off("load", doFly); }; + return () => { + map.off("load", doFly); + }; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [map, demoConfig, imageryCenter]); - - // Predict - const { predict, isPredicting, predictions, predictionBBox, predictionGridZoom } = useFairPredict(); - - // Handlers + // Predict + const { + predict, + isPredicting, + predictions, + predictionBBox, + predictionGridZoom, + } = useFairPredict(); + + // Handlers const handleSelectModel = (model: { id: string }) => { setModelId(model.id); setResolution(TryFairResolution.MID); @@ -136,7 +147,8 @@ export const TryFairPage = () => { setResolution(res); setIsDirty(true); // Zoom the map to the tile zoom that matches this resolution so the grid - if (map) map.easeTo({ zoom: TRY_FAIR_RESOLUTION_ZOOM[res], essential: true }); + if (map) + map.easeTo({ zoom: TRY_FAIR_RESOLUTION_ZOOM[res], essential: true }); }; const handleOutputTypeChange = (type: TryFairMapOutputType) => { @@ -164,7 +176,9 @@ export const TryFairPage = () => { // Invert confidence_threshold for the API: a lower threshold value produces better results const apiParams = Object.fromEntries( Object.entries(paramValues).map(([k, v]) => - k === "confidence_threshold" ? [k, parseFloat((1 - Number(v)).toFixed(2))] : [k, v], + k === "confidence_threshold" + ? [k, parseFloat((1 - Number(v)).toFixed(2))] + : [k, v], ), ); predict({ diff --git a/frontend/src/components/layouts/navbar/navbar.tsx b/frontend/src/components/layouts/navbar/navbar.tsx index b13708555..5e83d86cb 100644 --- a/frontend/src/components/layouts/navbar/navbar.tsx +++ b/frontend/src/components/layouts/navbar/navbar.tsx @@ -114,8 +114,11 @@ export const NavBar = () => { ? true : false } - variant={ location.pathname.includes(APPLICATION_ROUTES.TRY_FAIR) ? ButtonVariant.TERTIARY : ButtonVariant.PRIMARY} - + variant={ + location.pathname.includes(APPLICATION_ROUTES.TRY_FAIR) + ? ButtonVariant.TERTIARY + : ButtonVariant.PRIMARY + } onClick={() => { /* * Set the `backgroundLocation` in location state so that when we open the authentication modal we still see the current page in the background. @@ -158,8 +161,11 @@ export const NavBar = () => { ) : ( 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 1e530b83e..ed1093b50 100644 --- a/frontend/src/features/try-fair/components/try-fair-sidebar.tsx +++ b/frontend/src/features/try-fair/components/try-fair-sidebar.tsx @@ -8,11 +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 { OUTPUT_TYPES, RESOLUTIONS } from "@/features/try-fair/utils/common"; import { - OUTPUT_TYPES, - RESOLUTIONS, -} from "@/features/try-fair/utils/common"; -import { BaseModelStacItem, InferenceParam } from "@/features/try-fair/api/stac"; + BaseModelStacItem, + InferenceParam, +} from "@/features/try-fair/api/stac"; type TryFairSidebarProps = { selectedModel: BaseModelStacItem | null; @@ -93,10 +93,11 @@ export const TryFairSidebar = ({ onClick={() => onOutputTypeChange(type)} title={label} aria-label={label} - className={`flex-1 flex items-center justify-center py-2 rounded-lg ${outputType === type - ? "bg-secondary text-primary" - : "bg-off-white" - }`} + className={`flex-1 flex items-center justify-center py-2 rounded-lg ${ + outputType === type + ? "bg-secondary text-primary" + : "bg-off-white" + }`} > {icon} @@ -143,19 +144,17 @@ export const TryFairSidebar = ({ key={value} type="button" onClick={() => onResolutionChange(value)} - className={`flex-1 gap-1 flex text-xs items-center justify-center py-2 rounded-lg ${resolution === value ? "bg-secondary" : "bg-off-white" - }`} + className={`flex-1 gap-1 flex text-xs items-center justify-center py-2 rounded-lg ${ + resolution === value ? "bg-secondary" : "bg-off-white" + }`} > {label} ))}
      -
      - - {/* Confidence is the only inference param exposed in the UI. Other params (iou_threshold, min_class_value, …) stay at their STAC defaults and are forwarded to the predict call */} @@ -178,22 +177,21 @@ export const TryFairSidebar = ({
      - onParamChange(key, parseFloat(e.target.value))} - + onChange={(e) => + onParamChange(key, parseFloat(e.target.value)) + } className="try-fair-confidence-slider flex-1 h-1.5 rounded-full appearance-none cursor-pointer outline-none" style={{ background: `linear-gradient(90deg, #0088FF 0%, #FF383C 100%)`, }} /> -
      ); @@ -203,9 +201,11 @@ export const TryFairSidebar = ({ ); }; - - {/* Confidence */} - {/*
      +{ + /* Confidence */ +} +{ + /*

      {TRY_FAIR_PAGE_CONTENT.sidebar.parameters.confidence.label} @@ -231,4 +231,5 @@ export const TryFairSidebar = ({ />

      -
      */} +
      */ +} diff --git a/frontend/src/features/try-fair/hooks/use-fair-predict.tsx b/frontend/src/features/try-fair/hooks/use-fair-predict.tsx index a6f52492f..0accc8dbc 100644 --- a/frontend/src/features/try-fair/hooks/use-fair-predict.tsx +++ b/frontend/src/features/try-fair/hooks/use-fair-predict.tsx @@ -21,8 +21,6 @@ type PredictArgs = { params: Record; }; - - export const useFairPredict = () => { const { mutate, isPending, data, error, reset } = useMutation< PredictResult, 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 079e973e4..ddc52ad90 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 @@ -21,7 +21,7 @@ export const useTryFairParams = () => { resolution: parseAsString.withDefault(TryFairResolution.LOW), confidence: parseAsFloat.withDefault(0.5), }, - { history: "replace" } + { history: "replace" }, ); const outputType = VALID_OUTPUTS.includes(params.output) @@ -39,10 +39,8 @@ export const useTryFairParams = () => { confidence: params.confidence, setModelId: (id: string) => setParams({ model: id }), - setOutputType: (type: TryFairMapOutputType) => - setParams({ output: type }), - setResolution: (res: TryFairResolution) => - setParams({ resolution: res }), + setOutputType: (type: TryFairMapOutputType) => setParams({ output: type }), + setResolution: (res: TryFairResolution) => setParams({ resolution: res }), setConfidence: (val: number) => setParams({ confidence: val }), }; }; diff --git a/frontend/src/features/try-fair/models.ts b/frontend/src/features/try-fair/models.ts index fe3b0a118..ebac288f4 100644 --- a/frontend/src/features/try-fair/models.ts +++ b/frontend/src/features/try-fair/models.ts @@ -76,8 +76,6 @@ export const MODELS_LIST: TryFairModel[] = [ }, ]; - - export type DemoModelConfig = { baseModelId: string; localModelUri: string; diff --git a/frontend/src/features/try-fair/utils/common.tsx b/frontend/src/features/try-fair/utils/common.tsx index 5211e4f05..e20ec9d27 100644 --- a/frontend/src/features/try-fair/utils/common.tsx +++ b/frontend/src/features/try-fair/utils/common.tsx @@ -49,8 +49,6 @@ export const OUTPUT_TYPES: { }, ]; - - export const TRY_FAIR_RESOLUTION_ZOOM: Record = { [TryFairResolution.LOW]: 17, [TryFairResolution.MID]: 18, diff --git a/frontend/src/features/try-fair/utils/helpers.ts b/frontend/src/features/try-fair/utils/helpers.ts index 8fedabd60..58d5f2915 100644 --- a/frontend/src/features/try-fair/utils/helpers.ts +++ b/frontend/src/features/try-fair/utils/helpers.ts @@ -1,4 +1,3 @@ - // ── Geometry helpers ────────────────────────────────────────────────────────── import { BBOX } from "@/types"; @@ -13,7 +12,6 @@ export const ringCentroid = (ring: number[][]): [number, number] => { return [sumX / ring.length, sumY / ring.length]; }; - /** * Returns exactly ONE centroid per feature regardless of geometry type. * - Point → the coordinate itself @@ -21,7 +19,9 @@ export const ringCentroid = (ring: number[][]): [number, number] => { * - MultiPolygon → centroid computed across ALL sub-polygon exterior rings * (one representative point for the whole shape) */ -export const featureCentroid = (feature: GeoJSON.Feature): [number, number] | null => { +export const featureCentroid = ( + feature: GeoJSON.Feature, +): [number, number] | null => { const geom = feature.geometry; if (geom.type === "Point") return geom.coordinates as [number, number]; if (geom.type === "Polygon") { @@ -43,11 +43,13 @@ export const toPointCollection = ( features: fc.features.flatMap((f) => { const coords = featureCentroid(f); if (!coords) return []; - return [{ - type: "Feature" as const, - geometry: { type: "Point" as const, coordinates: coords }, - properties: f.properties, - }]; + return [ + { + type: "Feature" as const, + geometry: { type: "Point" as const, coordinates: coords }, + properties: f.properties, + }, + ]; }), }); @@ -70,7 +72,6 @@ export type ChoroplethBucket = { label: string; }; - // ── Choropleth grid spec (mirrors draggable-grid.tsx — must stay in sync) ─── type GridSpec = { columns: number; rows: number }; @@ -140,13 +141,29 @@ const buildTileAlignedChoropleth = ( const features: GeoJSON.Feature[] = []; for (let r = 0; r < numRows; r++) { for (let c = 0; c < numCols; c++) { - const { lon_deg: w, lat_deg: n } = num2deg(anchorX + c, anchorY + r, gridZoom); - const { lon_deg: e, lat_deg: s } = num2deg(anchorX + c + 1, anchorY + r + 1, gridZoom); + const { lon_deg: w, lat_deg: n } = num2deg( + anchorX + c, + anchorY + r, + gridZoom, + ); + const { lon_deg: e, lat_deg: s } = num2deg( + anchorX + c + 1, + anchorY + r + 1, + gridZoom, + ); features.push({ type: "Feature", geometry: { type: "Polygon", - coordinates: [[[w, s], [e, s], [e, n], [w, n], [w, s]]], + coordinates: [ + [ + [w, s], + [e, s], + [e, n], + [w, n], + [w, s], + ], + ], }, properties: { count: counts[r][c] }, }); @@ -181,7 +198,12 @@ const buildEqualDegreeChoropleth = ( Math.floor((cy - south) / cellH), CHOROPLETH_GRID_ROWS - 1, ); - if (col >= 0 && col < CHOROPLETH_GRID_COLS && row >= 0 && row < CHOROPLETH_GRID_ROWS) { + if ( + col >= 0 && + col < CHOROPLETH_GRID_COLS && + row >= 0 && + row < CHOROPLETH_GRID_ROWS + ) { counts[row][col]++; } } @@ -197,7 +219,15 @@ const buildEqualDegreeChoropleth = ( type: "Feature", geometry: { type: "Polygon", - coordinates: [[[w, s], [e, s], [e, n], [w, n], [w, s]]], + coordinates: [ + [ + [w, s], + [e, s], + [e, n], + [w, n], + [w, s], + ], + ], }, properties: { count: counts[r][c] }, }); @@ -228,8 +258,7 @@ export const computeChoroplethBuckets = ( min: i + 1, max: i === CHOROPLETH_COLORS.length - 1 ? Infinity : i + 1, color, - label: - i === CHOROPLETH_COLORS.length - 1 ? `${i + 1}+` : `${i + 1}`, + label: i === CHOROPLETH_COLORS.length - 1 ? `${i + 1}+` : `${i + 1}`, })); } @@ -238,11 +267,7 @@ export const computeChoroplethBuckets = ( const min = i * step + 1; const max = i === CHOROPLETH_COLORS.length - 1 ? Infinity : (i + 1) * step; const label = - max === Infinity - ? `${min}+` - : min === max - ? `${min}` - : `${min}–${max}`; // en-dash + max === Infinity ? `${min}+` : min === max ? `${min}` : `${min}–${max}`; // en-dash return { min, max, color, label }; }); -}; \ No newline at end of file +}; From aab165c830a45d343e3f5b3bc86518ae9f4b0811 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Sun, 24 May 2026 20:56:38 +0100 Subject: [PATCH 37/62] chore: set config and update chlorpleth colors --- .../components/map/draggable-grid.tsx | 111 ++++-------------- .../try-fair/components/map/try-fair-map.tsx | 12 +- .../src/features/try-fair/utils/common.tsx | 106 ++++++++++++----- .../src/features/try-fair/utils/helpers.ts | 85 +++++++++----- 4 files changed, 156 insertions(+), 158 deletions(-) diff --git a/frontend/src/features/try-fair/components/map/draggable-grid.tsx b/frontend/src/features/try-fair/components/map/draggable-grid.tsx index d0745b5e0..61a94f786 100644 --- a/frontend/src/features/try-fair/components/map/draggable-grid.tsx +++ b/frontend/src/features/try-fair/components/map/draggable-grid.tsx @@ -10,30 +10,8 @@ import { useRef, useState, } from "react"; +import { DEFAULT_SELECTED_GRID, MAX_GRID_ZOOM, MIN_GRID_ZOOM, SELECTED_GRID_BY_ZOOM, SelectedGridSpec, VISIBLE_GRID_COLUMNS, VISIBLE_GRID_ROWS } from "@/features/try-fair/utils/common"; -// ── Grid constants ─────────────────────────────────────────────────────────── - -/** Visible draggable grid (what users see). */ -const VISIBLE_GRID_COLUMNS = 4; -const VISIBLE_GRID_ROWS = 4; - -type SelectedGridSpec = { columns: number; rows: number }; - -/** - * Selected tile footprint (what gets sent to prediction), configurable by - * tile zoom level. The visible grid stays 4x4 and is drawn inside this area. - */ -const DEFAULT_SELECTED_GRID: SelectedGridSpec = { columns: 2, rows: 2 }; -const SELECTED_GRID_BY_ZOOM: Record = { - 17: { columns: 2, rows: 2 }, - 18: { columns: 2, rows: 2 }, - 19: { columns: 3, rows: 3 }, - 20: { columns: 3, rows: 3 }, -}; - -const BASE_GRID_ZOOM = 17; -const MIN_GRID_ZOOM = BASE_GRID_ZOOM; -const MAX_GRID_ZOOM = 22; // ── Types ──────────────────────────────────────────────────────────────────── @@ -124,12 +102,9 @@ type GridScreenGeometry = { topRight: { x: number; y: number }; }; -const toPointString = (line: { - x1: number; - y1: number; - x2: number; - y2: number; -}): string => `${line.x1},${line.y1} ${line.x2},${line.y2}`; +const toPointString = ( + line: { x1: number; y1: number; x2: number; y2: number }, +): string => `${line.x1},${line.y1} ${line.x2},${line.y2}`; // ── Component ──────────────────────────────────────────────────────────────── @@ -146,8 +121,9 @@ export const TryFairDraggableGrid = ({ center?: [number, number]; }) => { const [anchor, setAnchor] = useState(null); - const [screenGeometry, setScreenGeometry] = - useState(null); + const [screenGeometry, setScreenGeometry] = useState( + null, + ); const [dragState, setDragState] = useState({ isDragging: false, startAnchor: null, @@ -163,9 +139,7 @@ export const TryFairDraggableGrid = ({ // Initialize / recenter grid from imagery center using the active tile zoom. useEffect(() => { if (!map) return; - const nextCenter = center - ? ([center[0], center[1]] as [number, number]) - : null; + const nextCenter = center ? ([center[0], center[1]] as [number, number]) : null; const previousCenter = previousCenterRef.current; const centerChanged = !!nextCenter && @@ -237,14 +211,10 @@ export const TryFairDraggableGrid = ({ const x = anchor.x + (column / VISIBLE_GRID_COLUMNS) * selected.columns; const top = num2deg(x, anchor.y, anchor.z); const bottom = num2deg(x, anchor.y + selected.rows, anchor.z); - const p1 = map.project({ - lng: top.lon_deg, - lat: top.lat_deg, - } as LngLatLike); - const p2 = map.project({ - lng: bottom.lon_deg, - lat: bottom.lat_deg, - } as LngLatLike); + const p1 = map.project({ lng: top.lon_deg, lat: top.lat_deg } as LngLatLike); + const p2 = map.project( + { lng: bottom.lon_deg, lat: bottom.lat_deg } as LngLatLike, + ); verticalLines.push({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }); } @@ -252,29 +222,20 @@ export const TryFairDraggableGrid = ({ const y = anchor.y + (row / VISIBLE_GRID_ROWS) * selected.rows; const left = num2deg(anchor.x, y, anchor.z); const right = num2deg(anchor.x + selected.columns, y, anchor.z); - const p1 = map.project({ - lng: left.lon_deg, - lat: left.lat_deg, - } as LngLatLike); - const p2 = map.project({ - lng: right.lon_deg, - lat: right.lat_deg, - } as LngLatLike); + const p1 = map.project({ lng: left.lon_deg, lat: left.lat_deg } as LngLatLike); + const p2 = map.project({ lng: right.lon_deg, lat: right.lat_deg } as LngLatLike); horizontalLines.push({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }); } const topRight = verticalLines[VISIBLE_GRID_COLUMNS] ? { - x: verticalLines[VISIBLE_GRID_COLUMNS].x1, - y: verticalLines[VISIBLE_GRID_COLUMNS].y1, - } + x: verticalLines[VISIBLE_GRID_COLUMNS].x1, + y: verticalLines[VISIBLE_GRID_COLUMNS].y1, + } : { x: 0, y: 0 }; - const hasInvalidPoint = [...verticalLines, ...horizontalLines].some( - (line) => - [line.x1, line.y1, line.x2, line.y2].some( - (value) => !Number.isFinite(value), - ), + const hasInvalidPoint = [...verticalLines, ...horizontalLines].some((line) => + [line.x1, line.y1, line.x2, line.y2].some((value) => !Number.isFinite(value)), ); if (hasInvalidPoint) return; @@ -303,12 +264,7 @@ export const TryFairDraggableGrid = ({ }, [anchor, map, mapContainerRef, syncScreenGeometry]); useEffect(() => { - if ( - !dragState.isDragging || - !map || - !dragState.startAnchor || - !dragState.startTile - ) + if (!dragState.isDragging || !map || !dragState.startAnchor || !dragState.startTile) return; const { startAnchor, startTile } = dragState; @@ -325,11 +281,7 @@ export const TryFairDraggableGrid = ({ const dx = xtile - startTile.x; const dy = ytile - startTile.y; setAnchor( - clampAnchor({ - x: startAnchor.x + dx, - y: startAnchor.y + dy, - z: startAnchor.z, - }), + clampAnchor({ x: startAnchor.x + dx, y: startAnchor.y + dy, z: startAnchor.z }), ); }; @@ -360,11 +312,7 @@ export const TryFairDraggableGrid = ({ dragRafRef.current = null; } flushPendingPointer(); - if ( - dragState.dragPanWasEnabled && - map.dragPan && - !map.dragPan.isEnabled() - ) { + if (dragState.dragPanWasEnabled && map.dragPan && !map.dragPan.isEnabled()) { map.dragPan.enable(); } setDragState((prev) => ({ @@ -398,11 +346,7 @@ export const TryFairDraggableGrid = ({ if (!container) return; const rect = container.getBoundingClientRect(); const lngLat = map.unproject([e.clientX - rect.left, e.clientY - rect.top]); - const { xtile, ytile } = lngLatToTileCoords( - lngLat.lat, - lngLat.lng, - anchor.z, - ); + const { xtile, ytile } = lngLatToTileCoords(lngLat.lat, lngLat.lng, anchor.z); isUserPositionedRef.current = true; const dragPanWasEnabled = map.dragPan ? map.dragPan.isEnabled() : false; if (dragPanWasEnabled) map.dragPan.disable(); @@ -436,9 +380,7 @@ export const TryFairDraggableGrid = ({ points={toPointString(line)} fill="none" stroke="#EF4444" - strokeOpacity={ - index === 0 || index === VISIBLE_GRID_ROWS ? 1 : 0.75 - } + strokeOpacity={index === 0 || index === VISIBLE_GRID_ROWS ? 1 : 0.75} strokeWidth={index === 0 || index === VISIBLE_GRID_ROWS ? 2 : 1} /> ))} @@ -449,9 +391,8 @@ export const TryFairDraggableGrid = ({ type="button" onPointerDown={handleDragStart} title="Move grid" - className={`absolute z-20 pointer-events-auto rounded-full bg-white border border-primary p-1.5 shadow-sm ${ - dragState.isDragging ? "cursor-grabbing" : "cursor-grab" - }`} + className={`absolute z-20 pointer-events-auto rounded-full bg-white border border-primary p-1.5 shadow-sm ${dragState.isDragging ? "cursor-grabbing" : "cursor-grab" + }`} style={{ left: `${screenGeometry.topRight.x}px`, top: `${screenGeometry.topRight.y}px`, 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 9d1198da2..70a69e2f1 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 @@ -12,17 +12,9 @@ import { FitToBounds, ZoomControls, } from "@/components/map/controls"; +import { PREDICTION_LAYER_IDS } from "@/features/try-fair/utils/common"; + -// Prediction layer IDs (kept in sync with try-fair-prediction-results.tsx) -const PREDICTION_LAYER_IDS = [ - "try-fair-predictions-fill", - "try-fair-predictions-outline", - "try-fair-predictions-circle", - "try-fair-predictions-cluster", - "try-fair-predictions-cluster-count", - "try-fair-predictions-choropleth-fill", - "try-fair-predictions-choropleth-outline", -]; type TryFairMapProps = { map: Map | null; diff --git a/frontend/src/features/try-fair/utils/common.tsx b/frontend/src/features/try-fair/utils/common.tsx index e20ec9d27..625a11f6c 100644 --- a/frontend/src/features/try-fair/utils/common.tsx +++ b/frontend/src/features/try-fair/utils/common.tsx @@ -10,47 +10,89 @@ export const RESOLUTIONS: { label: string; size: number; }[] = [ - { - value: TryFairResolution.LOW, - label: TRY_FAIR_PAGE_CONTENT.sidebar.parameters.resolution.low, - size: 12, - }, - { - value: TryFairResolution.MID, - label: TRY_FAIR_PAGE_CONTENT.sidebar.parameters.resolution.mid, - size: 16, - }, - { - value: TryFairResolution.HIGH, - label: TRY_FAIR_PAGE_CONTENT.sidebar.parameters.resolution.high, - size: 18, - }, -]; + { + value: TryFairResolution.LOW, + label: TRY_FAIR_PAGE_CONTENT.sidebar.parameters.resolution.low, + size: 12, + }, + { + value: TryFairResolution.MID, + label: TRY_FAIR_PAGE_CONTENT.sidebar.parameters.resolution.mid, + size: 16, + }, + { + value: TryFairResolution.HIGH, + label: TRY_FAIR_PAGE_CONTENT.sidebar.parameters.resolution.high, + size: 18, + }, + ]; export const OUTPUT_TYPES: { type: TryFairMapOutputType; label: string; icon: React.ReactNode; }[] = [ - { - type: TryFairMapOutputType.POINTS, - label: "Points", - icon: , - }, - { - type: TryFairMapOutputType.POLYGON, - label: "Polygon", - icon: , - }, - { - type: TryFairMapOutputType.CLUSTER, - label: "Cluster", - icon: , - }, -]; + { + type: TryFairMapOutputType.POINTS, + label: "Points", + icon: , + }, + { + type: TryFairMapOutputType.POLYGON, + label: "Polygon", + icon: , + }, + { + type: TryFairMapOutputType.CLUSTER, + label: "Cluster", + icon: , + }, + ]; export const TRY_FAIR_RESOLUTION_ZOOM: Record = { [TryFairResolution.LOW]: 17, [TryFairResolution.MID]: 18, [TryFairResolution.HIGH]: 19, }; + + +// Prediction layer IDs (kept in sync with try-fair-prediction-results.tsx) +export const PREDICTION_LAYER_IDS = [ + "try-fair-predictions-fill", + "try-fair-predictions-outline", + "try-fair-predictions-circle", + "try-fair-predictions-cluster", + "try-fair-predictions-cluster-count", + "try-fair-predictions-choropleth-fill", + "try-fair-predictions-choropleth-outline", +]; + + +// ── Grid constants ─────────────────────────────────────────────────────────── + +/** Visible draggable grid (what users see). */ +export const VISIBLE_GRID_COLUMNS = 4; +export const VISIBLE_GRID_ROWS = 4; + +export type SelectedGridSpec = { columns: number; rows: number }; + +/** + * Selected tile footprint (what gets sent to prediction), configurable by + * tile zoom level. The visible grid stays 4x4 and is drawn inside this area. + */ +export const DEFAULT_SELECTED_GRID: SelectedGridSpec = { columns: 2, rows: 2 }; +export const SELECTED_GRID_BY_ZOOM: Record = { + 17: { columns: 2, rows: 2 }, + 18: { columns: 2, rows: 2 }, + 19: { columns: 3, rows: 3 }, + 20: { columns: 3, rows: 3 }, +}; + +export const BASE_GRID_ZOOM = 17; +export const MIN_GRID_ZOOM = BASE_GRID_ZOOM; +export const MAX_GRID_ZOOM = 22; +type GridSpec = { columns: number; rows: number }; +const DEFAULT_GRID_SPEC: GridSpec = { columns: 2, rows: 2 }; + +export const getGridSpec = (zoom: number): GridSpec => + SELECTED_GRID_BY_ZOOM[zoom] ?? DEFAULT_GRID_SPEC; diff --git a/frontend/src/features/try-fair/utils/helpers.ts b/frontend/src/features/try-fair/utils/helpers.ts index 58d5f2915..c49f4eef0 100644 --- a/frontend/src/features/try-fair/utils/helpers.ts +++ b/frontend/src/features/try-fair/utils/helpers.ts @@ -1,7 +1,12 @@ // ── Geometry helpers ────────────────────────────────────────────────────────── import { BBOX } from "@/types"; -import { deg2num, num2deg } from "@/utils/geo/geometry-utils"; +import { num2deg } from "@/utils/geo/geometry-utils"; +import { + getGridSpec, + VISIBLE_GRID_COLUMNS, + VISIBLE_GRID_ROWS, +} from "@/features/try-fair/utils/common"; /** * Returns the centroid of a single exterior ring (array of [lon, lat] positions). @@ -58,10 +63,10 @@ export const CHOROPLETH_GRID_ROWS = 5; /** Lavender → deep purple ramp (5 buckets, matches design) */ export const CHOROPLETH_COLORS = [ - "#EDE9FE", - "#C4B5FD", - "#8B5CF6", - "#6D28D9", + "#E5CEF2", + "#C58EE4", + "#A14AD5", + "#6E2D93", "#3B0764", ] as const; @@ -74,25 +79,32 @@ export type ChoroplethBucket = { // ── Choropleth grid spec (mirrors draggable-grid.tsx — must stay in sync) ─── -type GridSpec = { columns: number; rows: number }; -const DEFAULT_GRID_SPEC: GridSpec = { columns: 2, rows: 2 }; -const GRID_SPEC_BY_ZOOM: Record = { - 17: { columns: 3, rows: 3 }, - 18: { columns: 2, rows: 2 }, - 19: { columns: 3, rows: 3 }, - 20: { columns: 3, rows: 3 }, +/** + * Floor-free tile coordinate conversion (matches lngLatToTileCoords in + * draggable-grid.tsx). Unlike deg2num, this does NOT apply Math.floor so we + * get fractional tile positions needed for sub-tile choropleth cells. + */ +const lngLatToFractionalTile = ( + lat_deg: number, + lon_deg: number, + zoom: number, +): { xtile: number; ytile: number } => { + const lat_rad = (lat_deg * Math.PI) / 180; + const n = Math.pow(2, zoom); + return { + xtile: ((lon_deg + 180) / 360) * n, + ytile: ((1 - Math.asinh(Math.tan(lat_rad)) / Math.PI) / 2) * n, + }; }; -const getGridSpec = (zoom: number): GridSpec => - GRID_SPEC_BY_ZOOM[zoom] ?? DEFAULT_GRID_SPEC; /** * Divides `bbox` into a grid and counts how many prediction feature centroids * fall in each cell. * * When `gridZoom` is provided the cells are tile-aligned (using the same - * num2deg / deg2num math as the visual draggable grid), so the choropleth - * fills overlay exactly on top of the red grid. Without `gridZoom` it falls - * back to a simple 5×5 equal-degree division. + * num2deg / lngLatToFractionalTile math as the visual draggable grid), so the + * choropleth overlay sits exactly on top of the red grid lines. + * Without `gridZoom` it falls back to a simple 5×5 equal-degree division. */ export const buildChoropleth = ( predictions: GeoJSON.FeatureCollection, @@ -112,16 +124,26 @@ const buildTileAlignedChoropleth = ( bbox: BBOX, gridZoom: number, ): GeoJSON.FeatureCollection => { - // Recover the integer anchor tile from the bbox NW corner. - // The bbox was computed with num2deg so deg2num should give very nearly - // integer values — Math.round cleans up any floating-point drift. const [west, , , north] = bbox; - const { xtile, ytile } = deg2num(north, west, gridZoom); + + // Recover the fractional anchor tile from the bbox NW corner using the + // same floor-free formula as draggable-grid, then round to the nearest + // integer to clean up floating-point drift from num2deg→lngLat conversion. + const { xtile, ytile } = lngLatToFractionalTile(north, west, gridZoom); const anchorX = Math.round(xtile); const anchorY = Math.round(ytile); - const { columns: numCols, rows: numRows } = getGridSpec(gridZoom); - // Count predictions per tile cell + // The selected grid spec tells us how many TILES the selection spans. + // The choropleth must use the VISIBLE cell count (4×4) so it aligns with + // the visual grid lines. The step is a fraction of a tile: + // colStep = selectedCols / VISIBLE_GRID_COLUMNS (e.g. 2/4 = 0.5 tiles) + const { columns: selCols, rows: selRows } = getGridSpec(gridZoom); + const colStep = selCols / VISIBLE_GRID_COLUMNS; + const rowStep = selRows / VISIBLE_GRID_ROWS; + const numCols = VISIBLE_GRID_COLUMNS; + const numRows = VISIBLE_GRID_ROWS; + + // Count predictions per visual cell const counts: number[][] = Array.from({ length: numRows }, () => Array(numCols).fill(0), ); @@ -129,26 +151,27 @@ const buildTileAlignedChoropleth = ( const centroid = featureCentroid(feature); if (!centroid) continue; const [cx, cy] = centroid; - const { xtile: tx, ytile: ty } = deg2num(cy, cx, gridZoom); - const col = Math.floor(tx - anchorX); - const row = Math.floor(ty - anchorY); + const { xtile: tx, ytile: ty } = lngLatToFractionalTile(cy, cx, gridZoom); + // Map fractional offset from anchor into visual cell indices + const col = Math.floor((tx - anchorX) / colStep); + const row = Math.floor((ty - anchorY) / rowStep); if (col >= 0 && col < numCols && row >= 0 && row < numRows) { counts[row][col]++; } } - // Build one polygon per tile using exact tile-corner coordinates + // Build one polygon per visual cell using exact sub-tile corner coordinates const features: GeoJSON.Feature[] = []; for (let r = 0; r < numRows; r++) { for (let c = 0; c < numCols; c++) { const { lon_deg: w, lat_deg: n } = num2deg( - anchorX + c, - anchorY + r, + anchorX + c * colStep, + anchorY + r * rowStep, gridZoom, ); const { lon_deg: e, lat_deg: s } = num2deg( - anchorX + c + 1, - anchorY + r + 1, + anchorX + (c + 1) * colStep, + anchorY + (r + 1) * rowStep, gridZoom, ); features.push({ From 4ba4147b3ae0228416c13a4b779434ed2806ee9f Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Sun, 24 May 2026 21:04:57 +0100 Subject: [PATCH 38/62] chore: set config and update chlorpleth colors --- .../components/map/draggable-grid.tsx | 98 ++++++++++++++----- .../try-fair/components/map/try-fair-map.tsx | 2 - .../src/features/try-fair/utils/common.tsx | 66 ++++++------- 3 files changed, 103 insertions(+), 63 deletions(-) diff --git a/frontend/src/features/try-fair/components/map/draggable-grid.tsx b/frontend/src/features/try-fair/components/map/draggable-grid.tsx index 61a94f786..2f54da5f5 100644 --- a/frontend/src/features/try-fair/components/map/draggable-grid.tsx +++ b/frontend/src/features/try-fair/components/map/draggable-grid.tsx @@ -10,8 +10,15 @@ import { useRef, useState, } from "react"; -import { DEFAULT_SELECTED_GRID, MAX_GRID_ZOOM, MIN_GRID_ZOOM, SELECTED_GRID_BY_ZOOM, SelectedGridSpec, VISIBLE_GRID_COLUMNS, VISIBLE_GRID_ROWS } from "@/features/try-fair/utils/common"; - +import { + DEFAULT_SELECTED_GRID, + MAX_GRID_ZOOM, + MIN_GRID_ZOOM, + SELECTED_GRID_BY_ZOOM, + SelectedGridSpec, + VISIBLE_GRID_COLUMNS, + VISIBLE_GRID_ROWS, +} from "@/features/try-fair/utils/common"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -102,9 +109,12 @@ type GridScreenGeometry = { topRight: { x: number; y: number }; }; -const toPointString = ( - line: { x1: number; y1: number; x2: number; y2: number }, -): string => `${line.x1},${line.y1} ${line.x2},${line.y2}`; +const toPointString = (line: { + x1: number; + y1: number; + x2: number; + y2: number; +}): string => `${line.x1},${line.y1} ${line.x2},${line.y2}`; // ── Component ──────────────────────────────────────────────────────────────── @@ -121,9 +131,8 @@ export const TryFairDraggableGrid = ({ center?: [number, number]; }) => { const [anchor, setAnchor] = useState(null); - const [screenGeometry, setScreenGeometry] = useState( - null, - ); + const [screenGeometry, setScreenGeometry] = + useState(null); const [dragState, setDragState] = useState({ isDragging: false, startAnchor: null, @@ -139,7 +148,9 @@ export const TryFairDraggableGrid = ({ // Initialize / recenter grid from imagery center using the active tile zoom. useEffect(() => { if (!map) return; - const nextCenter = center ? ([center[0], center[1]] as [number, number]) : null; + const nextCenter = center + ? ([center[0], center[1]] as [number, number]) + : null; const previousCenter = previousCenterRef.current; const centerChanged = !!nextCenter && @@ -211,10 +222,14 @@ export const TryFairDraggableGrid = ({ const x = anchor.x + (column / VISIBLE_GRID_COLUMNS) * selected.columns; const top = num2deg(x, anchor.y, anchor.z); const bottom = num2deg(x, anchor.y + selected.rows, anchor.z); - const p1 = map.project({ lng: top.lon_deg, lat: top.lat_deg } as LngLatLike); - const p2 = map.project( - { lng: bottom.lon_deg, lat: bottom.lat_deg } as LngLatLike, - ); + const p1 = map.project({ + lng: top.lon_deg, + lat: top.lat_deg, + } as LngLatLike); + const p2 = map.project({ + lng: bottom.lon_deg, + lat: bottom.lat_deg, + } as LngLatLike); verticalLines.push({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }); } @@ -222,20 +237,29 @@ export const TryFairDraggableGrid = ({ const y = anchor.y + (row / VISIBLE_GRID_ROWS) * selected.rows; const left = num2deg(anchor.x, y, anchor.z); const right = num2deg(anchor.x + selected.columns, y, anchor.z); - const p1 = map.project({ lng: left.lon_deg, lat: left.lat_deg } as LngLatLike); - const p2 = map.project({ lng: right.lon_deg, lat: right.lat_deg } as LngLatLike); + const p1 = map.project({ + lng: left.lon_deg, + lat: left.lat_deg, + } as LngLatLike); + const p2 = map.project({ + lng: right.lon_deg, + lat: right.lat_deg, + } as LngLatLike); horizontalLines.push({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }); } const topRight = verticalLines[VISIBLE_GRID_COLUMNS] ? { - x: verticalLines[VISIBLE_GRID_COLUMNS].x1, - y: verticalLines[VISIBLE_GRID_COLUMNS].y1, - } + x: verticalLines[VISIBLE_GRID_COLUMNS].x1, + y: verticalLines[VISIBLE_GRID_COLUMNS].y1, + } : { x: 0, y: 0 }; - const hasInvalidPoint = [...verticalLines, ...horizontalLines].some((line) => - [line.x1, line.y1, line.x2, line.y2].some((value) => !Number.isFinite(value)), + const hasInvalidPoint = [...verticalLines, ...horizontalLines].some( + (line) => + [line.x1, line.y1, line.x2, line.y2].some( + (value) => !Number.isFinite(value), + ), ); if (hasInvalidPoint) return; @@ -264,7 +288,12 @@ export const TryFairDraggableGrid = ({ }, [anchor, map, mapContainerRef, syncScreenGeometry]); useEffect(() => { - if (!dragState.isDragging || !map || !dragState.startAnchor || !dragState.startTile) + if ( + !dragState.isDragging || + !map || + !dragState.startAnchor || + !dragState.startTile + ) return; const { startAnchor, startTile } = dragState; @@ -281,7 +310,11 @@ export const TryFairDraggableGrid = ({ const dx = xtile - startTile.x; const dy = ytile - startTile.y; setAnchor( - clampAnchor({ x: startAnchor.x + dx, y: startAnchor.y + dy, z: startAnchor.z }), + clampAnchor({ + x: startAnchor.x + dx, + y: startAnchor.y + dy, + z: startAnchor.z, + }), ); }; @@ -312,7 +345,11 @@ export const TryFairDraggableGrid = ({ dragRafRef.current = null; } flushPendingPointer(); - if (dragState.dragPanWasEnabled && map.dragPan && !map.dragPan.isEnabled()) { + if ( + dragState.dragPanWasEnabled && + map.dragPan && + !map.dragPan.isEnabled() + ) { map.dragPan.enable(); } setDragState((prev) => ({ @@ -346,7 +383,11 @@ export const TryFairDraggableGrid = ({ if (!container) return; const rect = container.getBoundingClientRect(); const lngLat = map.unproject([e.clientX - rect.left, e.clientY - rect.top]); - const { xtile, ytile } = lngLatToTileCoords(lngLat.lat, lngLat.lng, anchor.z); + const { xtile, ytile } = lngLatToTileCoords( + lngLat.lat, + lngLat.lng, + anchor.z, + ); isUserPositionedRef.current = true; const dragPanWasEnabled = map.dragPan ? map.dragPan.isEnabled() : false; if (dragPanWasEnabled) map.dragPan.disable(); @@ -380,7 +421,9 @@ export const TryFairDraggableGrid = ({ points={toPointString(line)} fill="none" stroke="#EF4444" - strokeOpacity={index === 0 || index === VISIBLE_GRID_ROWS ? 1 : 0.75} + strokeOpacity={ + index === 0 || index === VISIBLE_GRID_ROWS ? 1 : 0.75 + } strokeWidth={index === 0 || index === VISIBLE_GRID_ROWS ? 2 : 1} /> ))} @@ -391,8 +434,9 @@ export const TryFairDraggableGrid = ({ type="button" onPointerDown={handleDragStart} title="Move grid" - className={`absolute z-20 pointer-events-auto rounded-full bg-white border border-primary p-1.5 shadow-sm ${dragState.isDragging ? "cursor-grabbing" : "cursor-grab" - }`} + className={`absolute z-20 pointer-events-auto rounded-full bg-white border border-primary p-1.5 shadow-sm ${ + dragState.isDragging ? "cursor-grabbing" : "cursor-grab" + }`} style={{ left: `${screenGeometry.topRight.x}px`, top: `${screenGeometry.topRight.y}px`, 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 70a69e2f1..f30df72e7 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 @@ -14,8 +14,6 @@ import { } from "@/components/map/controls"; import { PREDICTION_LAYER_IDS } from "@/features/try-fair/utils/common"; - - type TryFairMapProps = { map: Map | null; mapContainerRef: RefObject; diff --git a/frontend/src/features/try-fair/utils/common.tsx b/frontend/src/features/try-fair/utils/common.tsx index 625a11f6c..eb073695c 100644 --- a/frontend/src/features/try-fair/utils/common.tsx +++ b/frontend/src/features/try-fair/utils/common.tsx @@ -10,44 +10,44 @@ export const RESOLUTIONS: { label: string; size: number; }[] = [ - { - value: TryFairResolution.LOW, - label: TRY_FAIR_PAGE_CONTENT.sidebar.parameters.resolution.low, - size: 12, - }, - { - value: TryFairResolution.MID, - label: TRY_FAIR_PAGE_CONTENT.sidebar.parameters.resolution.mid, - size: 16, - }, - { - value: TryFairResolution.HIGH, - label: TRY_FAIR_PAGE_CONTENT.sidebar.parameters.resolution.high, - size: 18, - }, - ]; + { + value: TryFairResolution.LOW, + label: TRY_FAIR_PAGE_CONTENT.sidebar.parameters.resolution.low, + size: 12, + }, + { + value: TryFairResolution.MID, + label: TRY_FAIR_PAGE_CONTENT.sidebar.parameters.resolution.mid, + size: 16, + }, + { + value: TryFairResolution.HIGH, + label: TRY_FAIR_PAGE_CONTENT.sidebar.parameters.resolution.high, + size: 18, + }, +]; export const OUTPUT_TYPES: { type: TryFairMapOutputType; label: string; icon: React.ReactNode; }[] = [ - { - type: TryFairMapOutputType.POINTS, - label: "Points", - icon: , - }, - { - type: TryFairMapOutputType.POLYGON, - label: "Polygon", - icon: , - }, - { - type: TryFairMapOutputType.CLUSTER, - label: "Cluster", - icon: , - }, - ]; + { + type: TryFairMapOutputType.POINTS, + label: "Points", + icon: , + }, + { + type: TryFairMapOutputType.POLYGON, + label: "Polygon", + icon: , + }, + { + type: TryFairMapOutputType.CLUSTER, + label: "Cluster", + icon: , + }, +]; export const TRY_FAIR_RESOLUTION_ZOOM: Record = { [TryFairResolution.LOW]: 17, @@ -55,7 +55,6 @@ export const TRY_FAIR_RESOLUTION_ZOOM: Record = { [TryFairResolution.HIGH]: 19, }; - // Prediction layer IDs (kept in sync with try-fair-prediction-results.tsx) export const PREDICTION_LAYER_IDS = [ "try-fair-predictions-fill", @@ -67,7 +66,6 @@ export const PREDICTION_LAYER_IDS = [ "try-fair-predictions-choropleth-outline", ]; - // ── Grid constants ─────────────────────────────────────────────────────────── /** Visible draggable grid (what users see). */ From 9d5e3133bd5504d77054e59aa5953d5b270a2268 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Sun, 24 May 2026 21:39:47 +0100 Subject: [PATCH 39/62] chore: set config and update chlorpleth colors --- .../src/features/try-fair/components/model-picker-modal.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 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 52ae78f89..4dae52654 100644 --- a/frontend/src/features/try-fair/components/model-picker-modal.tsx +++ b/frontend/src/features/try-fair/components/model-picker-modal.tsx @@ -35,11 +35,10 @@ export const ModelPicker: React.FC = ({ models, loading = false, }) => { - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(false); const [panelStyle, setPanelStyle] = useState({}); const triggerRef = useRef(null); const panelRef = useRef(null); - // Recompute panel position whenever it opens const updatePosition = useCallback(() => { if (!triggerRef.current) return; From 2ded3350a96ebd3f1ab30b8cd94f82baa15a498b Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Mon, 25 May 2026 15:10:20 +0100 Subject: [PATCH 40/62] feat: disabled buttons when prediction is running --- .../src/features/try-fair/components/model-picker-modal.tsx | 2 +- frontend/src/features/try-fair/components/try-fair-sidebar.tsx | 3 +++ frontend/src/features/try-fair/models.ts | 2 +- 3 files changed, 5 insertions(+), 2 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 4dae52654..71a40af6a 100644 --- a/frontend/src/features/try-fair/components/model-picker-modal.tsx +++ b/frontend/src/features/try-fair/components/model-picker-modal.tsx @@ -218,4 +218,4 @@ export const ModelPicker: React.FC = ({ )} ); -}; +}; \ No newline at end of file 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 ed1093b50..63eda7599 100644 --- a/frontend/src/features/try-fair/components/try-fair-sidebar.tsx +++ b/frontend/src/features/try-fair/components/try-fair-sidebar.tsx @@ -92,6 +92,7 @@ export const TryFairSidebar = ({ type="button" onClick={() => onOutputTypeChange(type)} title={label} + disabled={isPredicting} aria-label={label} className={`flex-1 flex items-center justify-center py-2 rounded-lg ${ outputType === type @@ -143,6 +144,7 @@ export const TryFairSidebar = ({
      - {!location.pathname.includes(APPLICATION_ROUTES.TRY_FAIR) && ( + {!isTryFairPage && (
      { /> ) : ( @@ -142,7 +135,7 @@ export const NavBar = () => { className={`${styles.nav} app-padding z-20 py-1 border-b border-gray-border`} > - {!location.pathname.includes(APPLICATION_ROUTES.TRY_FAIR) && ( + {!isTryFairPage && (
      @@ -162,18 +155,9 @@ export const NavBar = () => { ) : ( diff --git a/frontend/src/features/try-fair/components/map/draggable-grid.tsx b/frontend/src/features/try-fair/components/map/draggable-grid.tsx index 68c075367..894ce0e36 100644 --- a/frontend/src/features/try-fair/components/map/draggable-grid.tsx +++ b/frontend/src/features/try-fair/components/map/draggable-grid.tsx @@ -182,7 +182,7 @@ export const TryFairDraggableGrid = ({ isUserPositionedRef.current = false; // Snap the grid immediately to the map center at the current grid zoom. setAnchor(getCenteredAnchor(map.getCenter(), getGridZoomFromMap(map))); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [resolution]); // Re-center the grid when the model changes. @@ -192,7 +192,7 @@ export const TryFairDraggableGrid = ({ // Clear the remembered imagery center so the new model's center triggers a fresh snap. previousCenterRef.current = null; setAnchor(getCenteredAnchor(map.getCenter(), getGridZoomFromMap(map))); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [modelId]); // Re-tile at every tile zoom change while preserving the current area centre. diff --git a/frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx b/frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx index 6d309beee..39662c925 100644 --- a/frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx +++ b/frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx @@ -218,10 +218,7 @@ export const TryFairPredictionsLayer = ({ style={{ left: tooltip.x, top: tooltip.y }} > {/* Offset so the tooltip doesn't sit directly under the cursor */} -
      +

      Buildings detected 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 ddc52ad90..e94f4f464 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 @@ -18,7 +18,7 @@ export const useTryFairParams = () => { { model: parseAsString.withDefault("unet-segmentation"), output: parseAsString.withDefault(TryFairMapOutputType.POINTS), - resolution: parseAsString.withDefault(TryFairResolution.LOW), + resolution: parseAsString.withDefault(TryFairResolution.MID), confidence: parseAsFloat.withDefault(0.5), }, { history: "replace" }, From 9f75b8e4aef2ae9fc4b6589da0d8696561a5983f Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Mon, 25 May 2026 16:52:46 +0100 Subject: [PATCH 43/62] updated default resolution --- frontend/src/components/layouts/navbar/navbar.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/layouts/navbar/navbar.tsx b/frontend/src/components/layouts/navbar/navbar.tsx index f16d32fc4..ffa1f5abf 100644 --- a/frontend/src/components/layouts/navbar/navbar.tsx +++ b/frontend/src/components/layouts/navbar/navbar.tsx @@ -112,7 +112,9 @@ export const NavBar = () => { +

      + {highlightStartMapping && isTryFairPage && ( +
      + )} + +
      )}
      @@ -155,26 +162,31 @@ export const NavBar = () => { ) : ( - +
      + {highlightStartMapping && isTryFairPage && ( +
      + )} + +
      )} {AUTH_PROVIDER === "hanko" && }
      diff --git a/frontend/src/components/map/controls/fit-to-bounds-control.tsx b/frontend/src/components/map/controls/fit-to-bounds-control.tsx index f3ffc59db..5e0b5c273 100644 --- a/frontend/src/components/map/controls/fit-to-bounds-control.tsx +++ b/frontend/src/components/map/controls/fit-to-bounds-control.tsx @@ -4,6 +4,7 @@ import { Map } from "maplibre-gl"; import { MAP_CONTENT } from "@/constants"; import { ToolTip } from "@/components/ui/tooltip"; import { useCallback } from "react"; +import { cn } from "@/utils"; export const FitToBounds = ({ map, @@ -11,14 +12,18 @@ export const FitToBounds = ({ mobileClassName = "p-2.5 border border-gray-border md:border-0", rounded = true, onClick, - tooltipContent + tooltipContent, + buttonClassName, + iconClassName, }: { map: Map | null; bounds: any; mobileClassName?: string; rounded?: boolean; tooltipContent?: string; - + buttonClassName?: string; + iconClassName?: string; + /** Override the default fitBounds click handler. */ onClick?: () => void; }) => { @@ -34,16 +39,23 @@ export const FitToBounds = ({ }, [map, bounds, onClick]); return ( - + ); diff --git a/frontend/src/components/map/controls/layer-control.tsx b/frontend/src/components/map/controls/layer-control.tsx index d54662662..ecf343657 100644 --- a/frontend/src/components/map/controls/layer-control.tsx +++ b/frontend/src/components/map/controls/layer-control.tsx @@ -5,6 +5,7 @@ import { LayerStackIcon } from "@/components/ui/icons"; import { Map } from "maplibre-gl"; import { ToolTip } from "@/components/ui/tooltip"; import { useEffect, useMemo, useState } from "react"; +import { cn } from "@/utils"; import { GOOGLE_SATELLITE_BASEMAP_LAYER_ID, OSM_BASEMAP_LAYER_ID, @@ -20,12 +21,16 @@ export const LayerControl = ({ basemaps = true, hasTileServiceLayer = false, rounded = false, + triggerClassName, + iconClassName, }: { map: Map | null; layers: TLayers; basemaps?: boolean; hasTileServiceLayer?: boolean; rounded?: boolean; + triggerClassName?: string; + iconClassName?: string; }) => { const layerControlData = useMemo(() => { const layers_ = [ @@ -129,9 +134,13 @@ export const LayerControl = ({ disableCheveronIcon triggerComponent={
      - +
      } withCheckbox diff --git a/frontend/src/features/start-mapping/components/map/legend-control.tsx b/frontend/src/features/start-mapping/components/map/legend-control.tsx index bd6ee7bbb..fb1fcf6ce 100644 --- a/frontend/src/features/start-mapping/components/map/legend-control.tsx +++ b/frontend/src/features/start-mapping/components/map/legend-control.tsx @@ -10,6 +10,7 @@ export type LegendItem = { label: string; fillColor: string; fillOpacity: number; + shape?: "square" | "circle"; }; const statusLegend = [ @@ -39,12 +40,14 @@ const statusLegend = [ const FillLegendStyle = ({ fillColor, fillOpacity, + shape = "square", }: { fillColor: string; fillOpacity: number; + shape?: "square" | "circle"; }) => ( { @@ -103,8 +108,13 @@ export const Legend = ({ )} {!isSmallViewport && ( -

      - {title} +

      +
      +

      {title}

      + {subtitle && expandLegend && ( +

      {subtitle}

      + )} +
      - + -

      +
      )} {expandLegend && (
      - {legendItems.map(({ label, fillColor, fillOpacity }, id) => ( + {legendItems.map(({ label, fillColor, fillOpacity, shape }, id) => (

      {label}

      diff --git a/frontend/src/features/try-fair/components/map/chloropleth-legend.tsx b/frontend/src/features/try-fair/components/map/chloropleth-legend.tsx index 90d58b30b..7ef11d0db 100644 --- a/frontend/src/features/try-fair/components/map/chloropleth-legend.tsx +++ b/frontend/src/features/try-fair/components/map/chloropleth-legend.tsx @@ -21,5 +21,5 @@ export const TryFairChoroplethLegend = ({ fillOpacity, })); - return ; + return ; }; diff --git a/frontend/src/features/try-fair/components/map/draggable-grid.tsx b/frontend/src/features/try-fair/components/map/draggable-grid.tsx index c03bb770d..acd574676 100644 --- a/frontend/src/features/try-fair/components/map/draggable-grid.tsx +++ b/frontend/src/features/try-fair/components/map/draggable-grid.tsx @@ -1,5 +1,5 @@ import { ArrowMoveIcon } from "@/components/ui/icons"; -import { deg2num, num2deg } from "@/utils/geo/geometry-utils"; +import { num2deg } from "@/utils/geo/geometry-utils"; import { BBOX } from "@/types"; import { LngLatLike, Map } from "maplibre-gl"; import { @@ -12,10 +12,9 @@ import { } from "react"; import { DEFAULT_SELECTED_GRID, - MAX_GRID_ZOOM, - MIN_GRID_ZOOM, SELECTED_GRID_BY_ZOOM, SelectedGridSpec, + TRY_FAIR_RESOLUTION_ZOOM, VISIBLE_GRID_COLUMNS, VISIBLE_GRID_ROWS, } from "@/features/try-fair/utils/common"; @@ -70,12 +69,16 @@ const getCenteredAnchor = ( zoom: number, ): TileAnchor => { const selected = getSelectedGridSpec(zoom); - const { xtile, ytile } = deg2num(center.lat, center.lng, zoom); - return clampAnchor({ - x: xtile - Math.floor(selected.columns / 2), - y: ytile - Math.floor(selected.rows / 2), - z: zoom, - }); + const { xtile, ytile } = lngLatToTileCoords(center.lat, center.lng, zoom); + // Snap to integer tile boundaries so the visual grid always aligns with the + // bbox that gets sent to the prediction API (which uses getSnappedAnchor). + return getSnappedAnchor( + clampAnchor({ + x: xtile - selected.columns / 2, + y: ytile - selected.rows / 2, + z: zoom, + }), + ); }; const getSelectedGridBBox = (anchor: TileAnchor): BBOX => { @@ -96,13 +99,9 @@ const getSnappedAnchor = (anchor: TileAnchor): TileAnchor => y: Math.floor(anchor.y), }); -/** - * Keep draggable-grid tile zoom aligned with the visible map tile zoom - * (same offset logic used by tile-boundaries), but never below the - * base 17-tile grid requested for Try fAIr. - */ -const getGridZoomFromMap = (map: Map): number => - clamp(Math.round(map.getZoom() + 1), MIN_GRID_ZOOM, MAX_GRID_ZOOM); +/** Returns the tile zoom for the given resolution, falling back to MID. */ +const getTileZoomForResolution = (resolution?: TryFairResolution): number => + TRY_FAIR_RESOLUTION_ZOOM[resolution ?? TryFairResolution.MID]; type GridScreenGeometry = { verticalLines: { x1: number; y1: number; x2: number; y2: number }[]; @@ -155,7 +154,7 @@ export const TryFairDraggableGrid = ({ const pendingPointerRef = useRef<{ x: number; y: number } | null>(null); const dragRafRef = useRef(null); - // Initialize / recenter grid from imagery center using the active tile zoom. + // Initialize / recenter grid from imagery center using the resolution tile zoom. useEffect(() => { if (!map) return; const nextCenter = center @@ -172,19 +171,21 @@ export const TryFairDraggableGrid = ({ const target = nextCenter ? { lng: nextCenter[0], lat: nextCenter[1] } : map.getCenter(); - setAnchor(getCenteredAnchor(target, getGridZoomFromMap(map))); + setAnchor(getCenteredAnchor(target, getTileZoomForResolution(resolution))); isUserPositionedRef.current = false; } previousCenterRef.current = nextCenter; }, [map, center, anchor]); - // Re-center the grid when resolution changes (zoom will ease to match). + // Update the grid tile zoom when resolution changes, centering on the current map view. + // The map camera is never touched — only the grid tile structure updates. useEffect(() => { if (!map || resolution === undefined) return; + const newTileZoom = TRY_FAIR_RESOLUTION_ZOOM[resolution]; + const center = map.getCenter(); isUserPositionedRef.current = false; - // Snap the grid immediately to the map center at the current grid zoom. - setAnchor(getCenteredAnchor(map.getCenter(), getGridZoomFromMap(map))); + setAnchor(getCenteredAnchor({ lng: center.lng, lat: center.lat }, newTileZoom)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [resolution]); @@ -194,45 +195,25 @@ export const TryFairDraggableGrid = ({ isUserPositionedRef.current = false; // Clear the remembered imagery center so the new model's center triggers a fresh snap. previousCenterRef.current = null; - setAnchor(getCenteredAnchor(map.getCenter(), getGridZoomFromMap(map))); + setAnchor( + getCenteredAnchor(map.getCenter(), getTileZoomForResolution(resolution)), + ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [modelId]); - // Re-tile at every tile zoom change while preserving the current area centre. + // Initialise the anchor once on map-load if not already set by the imagery-centre effect. + // The grid is geo-fixed after this: it does NOT retile when the user scrolls/zooms. useEffect(() => { if (!map) return; - - const retileAtCurrentZoom = () => { - const nextGridZoom = getGridZoomFromMap(map); - setAnchor((prev) => { - if (!prev) { - const target = center - ? { lng: center[0], lat: center[1] } - : map.getCenter(); - return getCenteredAnchor(target, nextGridZoom); - } - if (prev.z === nextGridZoom) return prev; - - // Keep grid centered on the visible map unless the user explicitly dragged it. - if (!isUserPositionedRef.current) { - return getCenteredAnchor(map.getCenter(), nextGridZoom); - } - - const [west, south, east, north] = getSelectedGridBBox(prev); - const currentCenter = { - lng: (west + east) / 2, - lat: (south + north) / 2, - }; - return getCenteredAnchor(currentCenter, nextGridZoom); - }); - }; - - retileAtCurrentZoom(); - map.on("zoom", retileAtCurrentZoom); - return () => { - map.off("zoom", retileAtCurrentZoom); - }; - }, [map, center]); + setAnchor((prev) => { + if (prev) return prev; + const target = center + ? { lng: center[0], lat: center[1] } + : map.getCenter(); + return getCenteredAnchor(target, getTileZoomForResolution(resolution)); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); useEffect(() => { if (!anchor) return; @@ -279,9 +260,9 @@ export const TryFairDraggableGrid = ({ const topRight = verticalLines[VISIBLE_GRID_COLUMNS] ? { - x: verticalLines[VISIBLE_GRID_COLUMNS].x1, - y: verticalLines[VISIBLE_GRID_COLUMNS].y1, - } + x: verticalLines[VISIBLE_GRID_COLUMNS].x1, + y: verticalLines[VISIBLE_GRID_COLUMNS].y1, + } : { x: 0, y: 0 }; const hasInvalidPoint = [...verticalLines, ...horizontalLines].some( @@ -381,6 +362,9 @@ export const TryFairDraggableGrid = ({ ) { map.dragPan.enable(); } + // Snap to integer tile boundaries so the visual grid aligns with the + // bbox reported to the prediction API. + setAnchor((prev) => (prev ? getSnappedAnchor(prev) : prev)); setDragState((prev) => ({ ...prev, isDragging: false, @@ -464,13 +448,12 @@ export const TryFairDraggableGrid = ({ onPointerDown={handleDragStart} title={isPredicting ? "Grid locked during prediction" : "Move grid"} disabled={isPredicting} - className={`absolute z-20 pointer-events-auto rounded-full bg-white border border-primary p-1.5 shadow-sm ${ - isPredicting + className={`absolute z-20 pointer-events-auto rounded-full bg-white border border-primary p-1.5 shadow-sm ${isPredicting ? "opacity-40 cursor-not-allowed" : dragState.isDragging ? "cursor-grabbing" : "cursor-grab" - }`} + }`} style={{ left: `${screenGeometry.topRight.x}px`, top: `${screenGeometry.topRight.y}px`, diff --git a/frontend/src/features/try-fair/components/map/points-legend.tsx b/frontend/src/features/try-fair/components/map/points-legend.tsx new file mode 100644 index 000000000..f29f42136 --- /dev/null +++ b/frontend/src/features/try-fair/components/map/points-legend.tsx @@ -0,0 +1,23 @@ +import { Legend } from "@/features/start-mapping/components"; +import { LegendItem } from "@/features/start-mapping/components/map/legend-control"; + +type Props = { + totalCount: number; +}; + +const POINT_COLOR = "#A147D8"; + +export const TryFairPointsLegend = ({ totalCount }: Props) => { + if (totalCount === 0) return null; + + const items: LegendItem[] = [ + { + label: `${totalCount.toLocaleString()} buildings detected`, + fillColor: POINT_COLOR, + fillOpacity: 1, + shape: "circle", + }, + ]; + + 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 d6e3df915..7e8db0eeb 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 @@ -7,6 +7,7 @@ import { TryFairDraggableGrid } from "@/features/try-fair/components/map/draggab import { TryFairPredictionsLayer } from "@/features/try-fair/components/map/try-fair-prediction-results"; import { ChoroplethBucket } from "@/features/try-fair/utils/helpers"; import { TryFairChoroplethLegend } from "@/features/try-fair/components/map/chloropleth-legend"; +import { TryFairPointsLegend } from "@/features/try-fair/components/map/points-legend"; import { LayerControl, FitToBounds, @@ -29,6 +30,7 @@ type TryFairMapProps = { resolution?: TryFairResolution; modelId?: string | null; isPredicting?: boolean; + onGridZoom?: () => void; }; export const TryFairMap = ({ @@ -45,6 +47,7 @@ export const TryFairMap = ({ resolution, modelId, isPredicting = false, + onGridZoom, }: TryFairMapProps) => { const [choroplethBuckets, setChoroplethBuckets] = useState< ChoroplethBucket[] | null @@ -68,7 +71,8 @@ export const TryFairMap = ({ padding: 40, essential: true, }); - }, [map]); + onGridZoom?.(); + }, [map, onGridZoom]); // Disable all map interactions while a prediction is running so the grid // stays anchored over the area that was submitted. @@ -142,7 +146,6 @@ export const TryFairMap = ({ map={map} bounds={null} onClick={handleFitToGrid} - rounded={false} tooltipContent="Zoom to grid bounds" /> @@ -164,6 +167,12 @@ export const TryFairMap = ({ {outputType === TryFairMapOutputType.CLUSTER && ( )} + + {outputType === TryFairMapOutputType.POINTS && ( + + )}
      ); }; 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 4dae52654..93eeabbd7 100644 --- a/frontend/src/features/try-fair/components/model-picker-modal.tsx +++ b/frontend/src/features/try-fair/components/model-picker-modal.tsx @@ -119,7 +119,7 @@ export const ModelPicker: React.FC = ({
      {/* Header */}
      @@ -137,7 +137,7 @@ export const ModelPicker: React.FC = ({
      {/* 2-column card grid */} -
      +
      {models.map((model) => { const isSelected = selectedModel?.id === model.id; const tasks = model.properties["mlm:tasks"] ?? []; diff --git a/frontend/src/features/try-fair/components/try-fair-banner.tsx b/frontend/src/features/try-fair/components/try-fair-banner.tsx new file mode 100644 index 000000000..a17adcadf --- /dev/null +++ b/frontend/src/features/try-fair/components/try-fair-banner.tsx @@ -0,0 +1,41 @@ +import { CloseIcon } from "@/components/ui/icons"; + +type TryFairBannerProps = { + mapClickCount: number; + onDismiss: () => void; +}; + +export const TryFairBanner = ({ mapClickCount, onDismiss }: TryFairBannerProps) => { + const isSecondRun = mapClickCount >= 2; + + return ( +
      +
      + {isSecondRun ? ( + <> +

      Take it further

      +

      + Export your results and access advanced mapping tools. +

      + + + ) : ( + <> +

      Want more results?

      +

      + Try adjusting confidence or resolution — small changes can reveal more features. +

      + + )} +
      + +
      + ); +}; 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 b78792ce8..7836a925f 100644 --- a/frontend/src/features/try-fair/components/try-fair-sidebar.tsx +++ b/frontend/src/features/try-fair/components/try-fair-sidebar.tsx @@ -13,6 +13,8 @@ import { BaseModelStacItem, InferenceParam, } from "@/features/try-fair/api/stac"; +import { cn } from "@/utils"; +import useScreenSize from "@/hooks/use-screen-size"; type TryFairSidebarProps = { selectedModel: BaseModelStacItem | null; @@ -29,6 +31,7 @@ type TryFairSidebarProps = { onMap: () => void; isPredicting: boolean; isMapButtonDisabled: boolean; + className?: string; }; export const TryFairSidebar = ({ @@ -46,11 +49,26 @@ export const TryFairSidebar = ({ onMap, isPredicting, isMapButtonDisabled, + className, }: TryFairSidebarProps) => { + const { isSmallViewport } = useScreenSize(); + return ( -
      +
      {/* ── Model selector + Map button ── */} -
      +
      {/* Vertical divider */} -
      + {!isSmallViewport && ( +
      + )}
      @@ -157,9 +170,6 @@ export const TryFairSidebar = ({
      - {/* Confidence is the only inference param exposed in the UI. - Other params (iou_threshold, min_class_value, …) stay at their - STAC defaults and are forwarded to the predict call */} {inferenceParams .filter((p) => p.key === "confidence_threshold") .map(({ key, spec }) => { @@ -203,36 +213,3 @@ export const TryFairSidebar = ({
      ); }; - -{ - /* Confidence */ -} -{ - /*
      -
      -

      - {TRY_FAIR_PAGE_CONTENT.sidebar.parameters.confidence.label} -

      - - {confidence}% - -
      -
      - - onParamChange(key, parseFloat(e.target.value))} - - className="try-fair-confidence-slider flex-1 h-1.5 rounded-full appearance-none cursor-pointer outline-none" - style={{ - background: `linear-gradient(90deg, #0088FF 0%, #FF383C 100%)`, - }} - /> - -
      -
      */ -} diff --git a/frontend/src/hooks/use-tileservice.ts b/frontend/src/hooks/use-tileservice.ts index ab1fef30a..bd081ee4a 100644 --- a/frontend/src/hooks/use-tileservice.ts +++ b/frontend/src/hooks/use-tileservice.ts @@ -220,7 +220,7 @@ export const useTileServiceLayer = ({ } else { fitToBounds(); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [map, tileJSONMetadata]); const hasBounds = useMemo(() => { diff --git a/frontend/src/store/try-fair-store.ts b/frontend/src/store/try-fair-store.ts new file mode 100644 index 000000000..2997f2394 --- /dev/null +++ b/frontend/src/store/try-fair-store.ts @@ -0,0 +1,11 @@ +import { create } from "zustand"; + +type TryFairState = { + highlightStartMapping: boolean; + setHighlightStartMapping: (value: boolean) => void; +}; + +export const useTryFairStore = create((set) => ({ + highlightStartMapping: false, + setHighlightStartMapping: (value) => set({ highlightStartMapping: value }), +})); From 8ced984dde71f04214a1723154376c9620ff610f Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 26 May 2026 18:55:37 +0100 Subject: [PATCH 48/62] feat: implement corrections --- frontend/src/app/routes/try-fair.tsx | 9 ++++++++- .../src/components/layouts/navbar/navbar.tsx | 4 +++- .../components/map/legend-control.tsx | 4 +++- .../components/map/chloropleth-legend.tsx | 9 ++++++++- .../components/map/draggable-grid.tsx | 19 ++++++++++++------- .../try-fair/components/map/try-fair-map.tsx | 4 +--- .../try-fair/components/try-fair-banner.tsx | 13 +++++++++---- 7 files changed, 44 insertions(+), 18 deletions(-) diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index 49e835690..f9629e7cb 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -193,7 +193,14 @@ export const TryFairPage = () => { const isMapButtonDisabled = !isDirty || !latestBBox || !demoConfig; useEffect(() => { if (autoTriggeredRef.current) return; - if (!map || !tileJSONMetadata || !gridZoomed || isMapButtonDisabled || isPredicting) return; + if ( + !map || + !tileJSONMetadata || + !gridZoomed || + isMapButtonDisabled || + isPredicting + ) + return; autoTriggeredRef.current = true; handleMap(); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/src/components/layouts/navbar/navbar.tsx b/frontend/src/components/layouts/navbar/navbar.tsx index 572ce0446..f4300c8b3 100644 --- a/frontend/src/components/layouts/navbar/navbar.tsx +++ b/frontend/src/components/layouts/navbar/navbar.tsx @@ -119,7 +119,9 @@ export const NavBar = () => { rounded={isTryFairPage} size={isTryFairPage ? "medium" : "large"} variant={ - isTryFairPage ? ButtonVariant.TERTIARY : ButtonVariant.PRIMARY + isTryFairPage + ? ButtonVariant.TERTIARY + : ButtonVariant.PRIMARY } onClick={() => { /* diff --git a/frontend/src/features/start-mapping/components/map/legend-control.tsx b/frontend/src/features/start-mapping/components/map/legend-control.tsx index fb1fcf6ce..f99d4e530 100644 --- a/frontend/src/features/start-mapping/components/map/legend-control.tsx +++ b/frontend/src/features/start-mapping/components/map/legend-control.tsx @@ -110,7 +110,9 @@ export const Legend = ({ {!isSmallViewport && (
      -

      {title}

      +

      + {title} +

      {subtitle && expandLegend && (

      {subtitle}

      )} diff --git a/frontend/src/features/try-fair/components/map/chloropleth-legend.tsx b/frontend/src/features/try-fair/components/map/chloropleth-legend.tsx index 7ef11d0db..cce029ca8 100644 --- a/frontend/src/features/try-fair/components/map/chloropleth-legend.tsx +++ b/frontend/src/features/try-fair/components/map/chloropleth-legend.tsx @@ -21,5 +21,12 @@ export const TryFairChoroplethLegend = ({ fillOpacity, })); - return ; + return ( + + ); }; diff --git a/frontend/src/features/try-fair/components/map/draggable-grid.tsx b/frontend/src/features/try-fair/components/map/draggable-grid.tsx index acd574676..7b12dfb58 100644 --- a/frontend/src/features/try-fair/components/map/draggable-grid.tsx +++ b/frontend/src/features/try-fair/components/map/draggable-grid.tsx @@ -171,7 +171,9 @@ export const TryFairDraggableGrid = ({ const target = nextCenter ? { lng: nextCenter[0], lat: nextCenter[1] } : map.getCenter(); - setAnchor(getCenteredAnchor(target, getTileZoomForResolution(resolution))); + setAnchor( + getCenteredAnchor(target, getTileZoomForResolution(resolution)), + ); isUserPositionedRef.current = false; } @@ -185,7 +187,9 @@ export const TryFairDraggableGrid = ({ const newTileZoom = TRY_FAIR_RESOLUTION_ZOOM[resolution]; const center = map.getCenter(); isUserPositionedRef.current = false; - setAnchor(getCenteredAnchor({ lng: center.lng, lat: center.lat }, newTileZoom)); + setAnchor( + getCenteredAnchor({ lng: center.lng, lat: center.lat }, newTileZoom), + ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [resolution]); @@ -260,9 +264,9 @@ export const TryFairDraggableGrid = ({ const topRight = verticalLines[VISIBLE_GRID_COLUMNS] ? { - x: verticalLines[VISIBLE_GRID_COLUMNS].x1, - y: verticalLines[VISIBLE_GRID_COLUMNS].y1, - } + x: verticalLines[VISIBLE_GRID_COLUMNS].x1, + y: verticalLines[VISIBLE_GRID_COLUMNS].y1, + } : { x: 0, y: 0 }; const hasInvalidPoint = [...verticalLines, ...horizontalLines].some( @@ -448,12 +452,13 @@ export const TryFairDraggableGrid = ({ onPointerDown={handleDragStart} title={isPredicting ? "Grid locked during prediction" : "Move grid"} disabled={isPredicting} - className={`absolute z-20 pointer-events-auto rounded-full bg-white border border-primary p-1.5 shadow-sm ${isPredicting + className={`absolute z-20 pointer-events-auto rounded-full bg-white border border-primary p-1.5 shadow-sm ${ + isPredicting ? "opacity-40 cursor-not-allowed" : dragState.isDragging ? "cursor-grabbing" : "cursor-grab" - }`} + }`} style={{ left: `${screenGeometry.topRight.x}px`, top: `${screenGeometry.topRight.y}px`, 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 7e8db0eeb..ed36e171d 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 @@ -169,9 +169,7 @@ export const TryFairMap = ({ )} {outputType === TryFairMapOutputType.POINTS && ( - + )}
      ); diff --git a/frontend/src/features/try-fair/components/try-fair-banner.tsx b/frontend/src/features/try-fair/components/try-fair-banner.tsx index a17adcadf..a0a7b4a05 100644 --- a/frontend/src/features/try-fair/components/try-fair-banner.tsx +++ b/frontend/src/features/try-fair/components/try-fair-banner.tsx @@ -5,7 +5,10 @@ type TryFairBannerProps = { onDismiss: () => void; }; -export const TryFairBanner = ({ mapClickCount, onDismiss }: TryFairBannerProps) => { +export const TryFairBanner = ({ + mapClickCount, + onDismiss, +}: TryFairBannerProps) => { const isSecondRun = mapClickCount >= 2; return ( @@ -17,13 +20,15 @@ export const TryFairBanner = ({ mapClickCount, onDismiss }: TryFairBannerProps)

      Export your results and access advanced mapping tools.

      - ) : ( <> -

      Want more results?

      +

      + Want more results? +

      - Try adjusting confidence or resolution — small changes can reveal more features. + Try adjusting confidence or resolution — small changes can reveal + more features.

      )} From 4503e3aeef5816bb140d956c9295e1796ad8c473 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Thu, 28 May 2026 06:55:14 +0100 Subject: [PATCH 49/62] feat: implement corrections --- frontend/src/app/routes/try-fair.tsx | 14 +- .../src/components/landing/header/header.tsx | 5 +- frontend/src/components/ui/button/button.tsx | 4 +- .../components/map/draggable-grid.tsx | 154 ++++++++++++------ .../try-fair/components/map/try-fair-map.tsx | 2 + .../map/try-fair-prediction-results.tsx | 4 +- 6 files changed, 123 insertions(+), 60 deletions(-) diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index f9629e7cb..a5ddf8957 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -165,10 +165,7 @@ export const TryFairPage = () => { // Resolution only changes the grid tile structure — the map camera stays put. }; - const handleOutputTypeChange = (type: TryFairMapOutputType) => { - setOutputType(type); - // Output type switch is client side approach - if server side then we will need to re-predict - }; + // handleOutputTypeChange is defined after handleMap (below) so it can call it. const handleParamChange = useCallback( (key: string, value: number | string | boolean) => { @@ -247,6 +244,15 @@ export const TryFairPage = () => { }); }; + const handleOutputTypeChange = (type: TryFairMapOutputType) => { + setOutputType(type); + // If the grid has been moved since the last prediction, re-run at the new + // location rather than just switching the rendering of stale results. + if (isDirty && predictions) { + handleMap(); + } + }; + return ( <> diff --git a/frontend/src/components/landing/header/header.tsx b/frontend/src/components/landing/header/header.tsx index 4624653e0..9fcdb5cf1 100644 --- a/frontend/src/components/landing/header/header.tsx +++ b/frontend/src/components/landing/header/header.tsx @@ -21,7 +21,10 @@ export const Header = () => { title={SHARED_CONTENT.homepage.ctaPrimaryButton} nativeAnchor={false} > - diff --git a/frontend/src/components/ui/button/button.tsx b/frontend/src/components/ui/button/button.tsx index f6a368a87..1f8b96250 100644 --- a/frontend/src/components/ui/button/button.tsx +++ b/frontend/src/components/ui/button/button.tsx @@ -19,6 +19,7 @@ type ButtonProps = { type?: "button" | "submit"; contentClassName?: string; fontSize?: React.CSSProperties["fontSize"]; + capitalize?: boolean; }; const Button: React.FC = ({ children, @@ -32,6 +33,7 @@ const Button: React.FC = ({ rounded = false, type = "button", contentClassName, + capitalize = true, fontSize, }) => { const spinnerColor = variant === "primary" ? "white" : "red"; @@ -54,7 +56,7 @@ const Button: React.FC = ({ >
      diff --git a/frontend/src/features/try-fair/components/map/draggable-grid.tsx b/frontend/src/features/try-fair/components/map/draggable-grid.tsx index 7b12dfb58..fa1a5f707 100644 --- a/frontend/src/features/try-fair/components/map/draggable-grid.tsx +++ b/frontend/src/features/try-fair/components/map/draggable-grid.tsx @@ -1,5 +1,8 @@ -import { ArrowMoveIcon } from "@/components/ui/icons"; +import { ElipsisIcon, CloudDownloadIcon } from "@/components/ui/icons"; +import { DropDown } from "@/components/ui/dropdown"; +import { ToolTip } from "@/components/ui/tooltip"; import { num2deg } from "@/utils/geo/geometry-utils"; +import { geoJSONDowloader } from "@/utils/geo/geo-utils"; import { BBOX } from "@/types"; import { LngLatLike, Map } from "maplibre-gl"; import { @@ -19,6 +22,7 @@ import { VISIBLE_GRID_ROWS, } from "@/features/try-fair/utils/common"; import { TryFairResolution } from "@/enums/try-fair"; +import { TryFairMapOutputType } from "@/enums/try-fair"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -116,6 +120,15 @@ const toPointString = (line: { y2: number; }): string => `${line.x1},${line.y1} ${line.x2},${line.y2}`; +// ── Export helper ───────────────────────────────────────────────────────────── + +const exportPredictions = ( + predictions: GeoJSON.FeatureCollection, + outputType: TryFairMapOutputType, +) => { + geoJSONDowloader(predictions, `fair-predictions-${outputType.toLowerCase()}`); +}; + // ── Component ──────────────────────────────────────────────────────────────── export const TryFairDraggableGrid = ({ @@ -126,6 +139,8 @@ export const TryFairDraggableGrid = ({ resolution, modelId, isPredicting = false, + predictions, + outputType, }: { map: Map | null; mapContainerRef: RefObject; @@ -138,6 +153,10 @@ export const TryFairDraggableGrid = ({ modelId?: string | null; /** When true, grid dragging is disabled */ isPredicting?: boolean; + /** Current predictions — when present, shows the export dropdown */ + predictions?: GeoJSON.FeatureCollection | null; + /** Currently selected output type — used to name the export file */ + outputType?: TryFairMapOutputType; }) => { const [anchor, setAnchor] = useState(null); const [screenGeometry, setScreenGeometry] = @@ -148,12 +167,20 @@ export const TryFairDraggableGrid = ({ startTile: null, dragPanWasEnabled: false, }); - const handleRef = useRef(null); const previousCenterRef = useRef<[number, number] | null>(null); const isUserPositionedRef = useRef(false); const pendingPointerRef = useRef<{ x: number; y: number } | null>(null); const dragRafRef = useRef(null); + const hasPredictions = !!predictions && predictions.features.length > 0; + + // Hide the export dropdown once the grid is moved away from the prediction area. + // Reset when fresh predictions arrive. + const [gridMovedSincePredict, setGridMovedSincePredict] = useState(false); + useEffect(() => { + if (hasPredictions) setGridMovedSincePredict(false); + }, [predictions]); // eslint-disable-line react-hooks/exhaustive-deps + // Initialize / recenter grid from imagery center using the resolution tile zoom. useEffect(() => { if (!map) return; @@ -180,8 +207,7 @@ export const TryFairDraggableGrid = ({ previousCenterRef.current = nextCenter; }, [map, center, anchor]); - // Update the grid tile zoom when resolution changes, centering on the current map view. - // The map camera is never touched — only the grid tile structure updates. + // Update the grid tile zoom when resolution changes. useEffect(() => { if (!map || resolution === undefined) return; const newTileZoom = TRY_FAIR_RESOLUTION_ZOOM[resolution]; @@ -197,7 +223,6 @@ export const TryFairDraggableGrid = ({ useEffect(() => { if (!map || !modelId) return; isUserPositionedRef.current = false; - // Clear the remembered imagery center so the new model's center triggers a fresh snap. previousCenterRef.current = null; setAnchor( getCenteredAnchor(map.getCenter(), getTileZoomForResolution(resolution)), @@ -205,8 +230,7 @@ export const TryFairDraggableGrid = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [modelId]); - // Initialise the anchor once on map-load if not already set by the imagery-centre effect. - // The grid is geo-fixed after this: it does NOT retile when the user scrolls/zooms. + // Initialise the anchor once on map-load. useEffect(() => { if (!map) return; setAnchor((prev) => { @@ -230,20 +254,12 @@ export const TryFairDraggableGrid = ({ const verticalLines: GridScreenGeometry["verticalLines"] = []; const horizontalLines: GridScreenGeometry["horizontalLines"] = []; - // Draw a 4x4 visual grid inside the selected tile bbox using fractional - // tile coordinates, so we don't add extra tiles outside the selected area. for (let column = 0; column <= VISIBLE_GRID_COLUMNS; column++) { const x = anchor.x + (column / VISIBLE_GRID_COLUMNS) * selected.columns; const top = num2deg(x, anchor.y, anchor.z); const bottom = num2deg(x, anchor.y + selected.rows, anchor.z); - const p1 = map.project({ - lng: top.lon_deg, - lat: top.lat_deg, - } as LngLatLike); - const p2 = map.project({ - lng: bottom.lon_deg, - lat: bottom.lat_deg, - } as LngLatLike); + const p1 = map.project({ lng: top.lon_deg, lat: top.lat_deg } as LngLatLike); + const p2 = map.project({ lng: bottom.lon_deg, lat: bottom.lat_deg } as LngLatLike); verticalLines.push({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }); } @@ -251,14 +267,8 @@ export const TryFairDraggableGrid = ({ const y = anchor.y + (row / VISIBLE_GRID_ROWS) * selected.rows; const left = num2deg(anchor.x, y, anchor.z); const right = num2deg(anchor.x + selected.columns, y, anchor.z); - const p1 = map.project({ - lng: left.lon_deg, - lat: left.lat_deg, - } as LngLatLike); - const p2 = map.project({ - lng: right.lon_deg, - lat: right.lat_deg, - } as LngLatLike); + const p1 = map.project({ lng: left.lon_deg, lat: left.lat_deg } as LngLatLike); + const p2 = map.project({ lng: right.lon_deg, lat: right.lat_deg } as LngLatLike); horizontalLines.push({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }); } @@ -366,8 +376,6 @@ export const TryFairDraggableGrid = ({ ) { map.dragPan.enable(); } - // Snap to integer tile boundaries so the visual grid aligns with the - // bbox reported to the prediction API. setAnchor((prev) => (prev ? getSnappedAnchor(prev) : prev)); setDragState((prev) => ({ ...prev, @@ -376,7 +384,6 @@ export const TryFairDraggableGrid = ({ startTile: null, dragPanWasEnabled: false, })); - handleRef.current?.blur(); }; window.addEventListener("pointermove", handlePointerMove); @@ -392,7 +399,8 @@ export const TryFairDraggableGrid = ({ }; }, [dragState, map, mapContainerRef]); - const handleDragStart = (e: ReactPointerEvent) => { + // Generic drag start — works from any pointer-event source (SVG polygon, button, etc.) + const handleDragStart = (e: ReactPointerEvent) => { if (!map || !anchor || isPredicting) return; e.preventDefault(); e.stopPropagation(); @@ -406,6 +414,7 @@ export const TryFairDraggableGrid = ({ anchor.z, ); isUserPositionedRef.current = true; + setGridMovedSincePredict(true); const dragPanWasEnabled = map.dragPan ? map.dragPan.isEnabled() : false; if (dragPanWasEnabled) map.dragPan.disable(); setDragState({ @@ -417,10 +426,36 @@ export const TryFairDraggableGrid = ({ }; if (!screenGeometry) return null; + + const { verticalLines, horizontalLines, topRight } = screenGeometry; + + // Four corners of the grid boundary for the drag polygon + const polygonPoints = [ + `${verticalLines[0].x1},${verticalLines[0].y1}`, + `${verticalLines[VISIBLE_GRID_COLUMNS].x1},${verticalLines[VISIBLE_GRID_COLUMNS].y1}`, + `${verticalLines[VISIBLE_GRID_COLUMNS].x2},${verticalLines[VISIBLE_GRID_COLUMNS].y2}`, + `${verticalLines[0].x2},${verticalLines[0].y2}`, + ].join(" "); + + const cursorClass = isPredicting + ? "cursor-not-allowed" + : dragState.isDragging + ? "cursor-grabbing" + : "cursor-grab"; + return (
      - {screenGeometry.verticalLines.map((line, index) => ( + {/* Transparent drag surface covering the entire grid */} + + + {/* Grid lines */} + {verticalLines.map((line, index) => ( ))} - {screenGeometry.horizontalLines.map((line, index) => ( + {horizontalLines.map((line, index) => ( - + {/* Export dropdown — shown when results exist and grid hasn't moved */} + {hasPredictions && !gridMovedSincePredict && outputType && ( +
      + + + + } + > +
      + + + +
      +
      +
      + )}
      ); }; 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 ed36e171d..d3aee8153 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 @@ -129,6 +129,8 @@ export const TryFairMap = ({ resolution={resolution} modelId={modelId} isPredicting={isPredicting} + predictions={predictions} + outputType={outputType} /> )} diff --git a/frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx b/frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx index 39662c925..21afe034c 100644 --- a/frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx +++ b/frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx @@ -102,7 +102,7 @@ export const TryFairPredictionsLayer = ({ id: FILL_LAYER, type: "fill", source: SOURCE_ID, - paint: { "fill-color": "#A243DC", "fill-opacity": 0.45 }, + paint: { "fill-color": "#A243DC", "fill-opacity": 0.5 }, }); map.addLayer({ id: OUTLINE_LAYER, @@ -122,7 +122,7 @@ export const TryFairPredictionsLayer = ({ type: "circle", source: SOURCE_ID, paint: { - "circle-radius": 4, + "circle-radius": 2, "circle-color": "#A147D8", "circle-stroke-color": "#A147D8", "circle-stroke-width": 1, From e702b85e7e6c93ae795733e1dcc2082761c02257 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Thu, 28 May 2026 07:01:21 +0100 Subject: [PATCH 50/62] feat: implement corrections --- .../src/components/landing/header/header.tsx | 5 +---- .../components/map/draggable-grid.tsx | 20 +++++++++++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/landing/header/header.tsx b/frontend/src/components/landing/header/header.tsx index 9fcdb5cf1..06065fbd0 100644 --- a/frontend/src/components/landing/header/header.tsx +++ b/frontend/src/components/landing/header/header.tsx @@ -21,10 +21,7 @@ export const Header = () => { title={SHARED_CONTENT.homepage.ctaPrimaryButton} nativeAnchor={false} > - diff --git a/frontend/src/features/try-fair/components/map/draggable-grid.tsx b/frontend/src/features/try-fair/components/map/draggable-grid.tsx index fa1a5f707..af21e7e90 100644 --- a/frontend/src/features/try-fair/components/map/draggable-grid.tsx +++ b/frontend/src/features/try-fair/components/map/draggable-grid.tsx @@ -258,8 +258,14 @@ export const TryFairDraggableGrid = ({ const x = anchor.x + (column / VISIBLE_GRID_COLUMNS) * selected.columns; const top = num2deg(x, anchor.y, anchor.z); const bottom = num2deg(x, anchor.y + selected.rows, anchor.z); - const p1 = map.project({ lng: top.lon_deg, lat: top.lat_deg } as LngLatLike); - const p2 = map.project({ lng: bottom.lon_deg, lat: bottom.lat_deg } as LngLatLike); + const p1 = map.project({ + lng: top.lon_deg, + lat: top.lat_deg, + } as LngLatLike); + const p2 = map.project({ + lng: bottom.lon_deg, + lat: bottom.lat_deg, + } as LngLatLike); verticalLines.push({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }); } @@ -267,8 +273,14 @@ export const TryFairDraggableGrid = ({ const y = anchor.y + (row / VISIBLE_GRID_ROWS) * selected.rows; const left = num2deg(anchor.x, y, anchor.z); const right = num2deg(anchor.x + selected.columns, y, anchor.z); - const p1 = map.project({ lng: left.lon_deg, lat: left.lat_deg } as LngLatLike); - const p2 = map.project({ lng: right.lon_deg, lat: right.lat_deg } as LngLatLike); + const p1 = map.project({ + lng: left.lon_deg, + lat: left.lat_deg, + } as LngLatLike); + const p2 = map.project({ + lng: right.lon_deg, + lat: right.lat_deg, + } as LngLatLike); horizontalLines.push({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }); } From af828e24a26689012bf51560f529c523fccc9976 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Thu, 28 May 2026 14:50:36 +0100 Subject: [PATCH 51/62] feat: implement new layer actions --- .../map/controls/fit-to-bounds-control.tsx | 2 +- .../components/map/controls/zoom-control.tsx | 12 +- frontend/src/components/ui/icons/index.ts | 5 + .../icons/try-fair-google-satellite-icon.tsx | 18 + .../ui/icons/try-fair-imagery-icon.tsx | 18 + .../components/ui/icons/try-fair-osm-icon.tsx | 18 + .../icons/try-fair-prediction-output-icon.tsx | 58 ++ .../icons/try-fair-prediction-toggle-icon.tsx | 18 + .../components/map/draggable-grid.tsx | 552 ++++-------------- .../components/map/try-fair-layer-control.tsx | 263 +++++++++ .../try-fair/components/map/try-fair-map.tsx | 27 +- .../map/try-fair-prediction-results.tsx | 2 +- .../features/try-fair/hooks/use-grid-drag.ts | 221 +++++++ .../hooks/use-grid-screen-geometry.ts | 196 +++++++ .../features/try-fair/hooks/use-tile-grid.ts | 173 ++++++ .../try-fair/hooks/use-try-fair-params.tsx | 2 +- .../src/features/try-fair/utils/common.tsx | 14 +- .../src/features/try-fair/utils/tile-math.ts | 117 ++++ 18 files changed, 1252 insertions(+), 464 deletions(-) create mode 100644 frontend/src/components/ui/icons/try-fair-google-satellite-icon.tsx create mode 100644 frontend/src/components/ui/icons/try-fair-imagery-icon.tsx create mode 100644 frontend/src/components/ui/icons/try-fair-osm-icon.tsx create mode 100644 frontend/src/components/ui/icons/try-fair-prediction-output-icon.tsx create mode 100644 frontend/src/components/ui/icons/try-fair-prediction-toggle-icon.tsx create mode 100644 frontend/src/features/try-fair/components/map/try-fair-layer-control.tsx create mode 100644 frontend/src/features/try-fair/hooks/use-grid-drag.ts create mode 100644 frontend/src/features/try-fair/hooks/use-grid-screen-geometry.ts create mode 100644 frontend/src/features/try-fair/hooks/use-tile-grid.ts create mode 100644 frontend/src/features/try-fair/utils/tile-math.ts diff --git a/frontend/src/components/map/controls/fit-to-bounds-control.tsx b/frontend/src/components/map/controls/fit-to-bounds-control.tsx index 5e0b5c273..7a3b5947a 100644 --- a/frontend/src/components/map/controls/fit-to-bounds-control.tsx +++ b/frontend/src/components/map/controls/fit-to-bounds-control.tsx @@ -50,7 +50,7 @@ export const FitToBounds = ({ className={cn( "bg-white", isSmallViewport ? mobileClassName : "p-1.5", - rounded && "rounded-lg", + rounded && "rounded-[4px]", buttonClassName, )} onClick={fitToBounds} diff --git a/frontend/src/components/map/controls/zoom-control.tsx b/frontend/src/components/map/controls/zoom-control.tsx index 7f8060993..b3372a6c7 100644 --- a/frontend/src/components/map/controls/zoom-control.tsx +++ b/frontend/src/components/map/controls/zoom-control.tsx @@ -9,21 +9,23 @@ const ZoomButton = ({ onClick, disabled, icon, + rounded = false, }: { onClick: () => void; disabled: boolean; icon: string; + rounded?: boolean; }) => ( - ); -export const ZoomControls = ({ map }: { map: Map | null }) => { +export const ZoomControls = ({ map, rounded }: { map: Map | null; rounded?: boolean }) => { const currentZoom = useMapStore((state) => state.zoom); const handleZoomIn = useCallback(() => { @@ -39,12 +41,13 @@ export const ZoomControls = ({ map }: { map: Map | null }) => { }, [map, currentZoom]); return ( -
      +
      = Number(map?.getMaxZoom())} icon="+" + rounded={rounded} /> @@ -52,6 +55,7 @@ export const ZoomControls = ({ map }: { map: Map | null }) => { onClick={handleZoomOut} disabled={currentZoom <= Number(map?.getMinZoom())} icon="-" + rounded={rounded} />
      diff --git a/frontend/src/components/ui/icons/index.ts b/frontend/src/components/ui/icons/index.ts index 2686bf837..25196309b 100644 --- a/frontend/src/components/ui/icons/index.ts +++ b/frontend/src/components/ui/icons/index.ts @@ -68,3 +68,8 @@ export { FilledFlagIcon } from "./filled-flag-icon"; export { FilledLocationIcon } from "./filled-location-icon"; export { DownloadIcon } from "./download-icon"; export { RefreshIcon } from "./refresh-icon"; +export { TryFairImageryIcon } from "./try-fair-imagery-icon"; +export { TryFairOSMIcon } from "./try-fair-osm-icon"; +export { TryFairGoogleSatelliteIcon } from "./try-fair-google-satellite-icon"; +export { TryFairPredictionOutputIcon } from "./try-fair-prediction-output-icon"; +export { TryFairPredictionToggleIcon } from "./try-fair-prediction-toggle-icon"; diff --git a/frontend/src/components/ui/icons/try-fair-google-satellite-icon.tsx b/frontend/src/components/ui/icons/try-fair-google-satellite-icon.tsx new file mode 100644 index 000000000..139f2a6ea --- /dev/null +++ b/frontend/src/components/ui/icons/try-fair-google-satellite-icon.tsx @@ -0,0 +1,18 @@ +import { IconProps } from "@/types"; +import React from "react"; + +export const TryFairGoogleSatelliteIcon: React.FC = (props) => ( + + + +); diff --git a/frontend/src/components/ui/icons/try-fair-imagery-icon.tsx b/frontend/src/components/ui/icons/try-fair-imagery-icon.tsx new file mode 100644 index 000000000..b4ccdfaa1 --- /dev/null +++ b/frontend/src/components/ui/icons/try-fair-imagery-icon.tsx @@ -0,0 +1,18 @@ +import { IconProps } from "@/types"; +import React from "react"; + +export const TryFairImageryIcon: React.FC = (props) => ( + + + +); diff --git a/frontend/src/components/ui/icons/try-fair-osm-icon.tsx b/frontend/src/components/ui/icons/try-fair-osm-icon.tsx new file mode 100644 index 000000000..e221de0e7 --- /dev/null +++ b/frontend/src/components/ui/icons/try-fair-osm-icon.tsx @@ -0,0 +1,18 @@ +import { IconProps } from "@/types"; +import React from "react"; + +export const TryFairOSMIcon: React.FC = (props) => ( + + + +); diff --git a/frontend/src/components/ui/icons/try-fair-prediction-output-icon.tsx b/frontend/src/components/ui/icons/try-fair-prediction-output-icon.tsx new file mode 100644 index 000000000..1d5021beb --- /dev/null +++ b/frontend/src/components/ui/icons/try-fair-prediction-output-icon.tsx @@ -0,0 +1,58 @@ +import { TryFairMapOutputType } from "@/enums/try-fair"; +import { IconProps } from "@/types"; +import React from "react"; + +type Props = IconProps & { + outputType: TryFairMapOutputType; +}; + +export const TryFairPredictionOutputIcon: React.FC = ({ + outputType, + ...props +}) => { + if (outputType === TryFairMapOutputType.POINTS) { + return ( + + + + ); + } + + if (outputType === TryFairMapOutputType.CLUSTER) { + return ( + + + + + + + ); + } + + return ( + + + + ); +}; diff --git a/frontend/src/components/ui/icons/try-fair-prediction-toggle-icon.tsx b/frontend/src/components/ui/icons/try-fair-prediction-toggle-icon.tsx new file mode 100644 index 000000000..097b48879 --- /dev/null +++ b/frontend/src/components/ui/icons/try-fair-prediction-toggle-icon.tsx @@ -0,0 +1,18 @@ +import { IconProps } from "@/types"; +import React from "react"; + +export const TryFairPredictionToggleIcon: React.FC = (props) => ( + + + +); diff --git a/frontend/src/features/try-fair/components/map/draggable-grid.tsx b/frontend/src/features/try-fair/components/map/draggable-grid.tsx index af21e7e90..94c35305a 100644 --- a/frontend/src/features/try-fair/components/map/draggable-grid.tsx +++ b/frontend/src/features/try-fair/components/map/draggable-grid.tsx @@ -1,128 +1,51 @@ +import { RefObject, useEffect, useRef, useState } from "react"; +import { Map } from "maplibre-gl"; import { ElipsisIcon, CloudDownloadIcon } from "@/components/ui/icons"; import { DropDown } from "@/components/ui/dropdown"; import { ToolTip } from "@/components/ui/tooltip"; -import { num2deg } from "@/utils/geo/geometry-utils"; import { geoJSONDowloader } from "@/utils/geo/geo-utils"; import { BBOX } from "@/types"; -import { LngLatLike, Map } from "maplibre-gl"; -import { - PointerEvent as ReactPointerEvent, - RefObject, - useCallback, - useEffect, - useRef, - useState, -} from "react"; +import { TryFairResolution } from "@/enums/try-fair"; +import { TryFairMapOutputType } from "@/enums/try-fair"; import { - DEFAULT_SELECTED_GRID, - SELECTED_GRID_BY_ZOOM, - SelectedGridSpec, - TRY_FAIR_RESOLUTION_ZOOM, VISIBLE_GRID_COLUMNS, VISIBLE_GRID_ROWS, } from "@/features/try-fair/utils/common"; -import { TryFairResolution } from "@/enums/try-fair"; -import { TryFairMapOutputType } from "@/enums/try-fair"; - -// ── Types ──────────────────────────────────────────────────────────────────── - -type TileAnchor = { x: number; y: number; z: number }; - -type DragState = { - isDragging: boolean; - startAnchor: TileAnchor | null; - startTile: { x: number; y: number } | null; - dragPanWasEnabled: boolean; -}; - -// ── Pure helpers ───────────────────────────────────────────────────────────── - -const clamp = (v: number, lo: number, hi: number) => - Math.min(Math.max(v, lo), hi); - -const getSelectedGridSpec = (zoom: number): SelectedGridSpec => - SELECTED_GRID_BY_ZOOM[zoom] ?? DEFAULT_SELECTED_GRID; - -const lngLatToTileCoords = ( - lat_deg: number, - lon_deg: number, - zoom: number, -): { xtile: number; ytile: number } => { - const lat_rad = (lat_deg * Math.PI) / 180; - const n = Math.pow(2, zoom); - return { - xtile: ((lon_deg + 180) / 360) * n, - ytile: ((1 - Math.asinh(Math.tan(lat_rad)) / Math.PI) / 2) * n, - }; -}; - -const clampAnchor = (a: TileAnchor): TileAnchor => { - const selected = getSelectedGridSpec(a.z); - const max = Math.pow(2, a.z); - const maxX = Math.max(0, max - selected.columns); - const maxY = Math.max(0, max - selected.rows); - return { - ...a, - x: clamp(a.x, 0, maxX), - y: clamp(a.y, 0, maxY), - }; -}; - -const getCenteredAnchor = ( - center: { lng: number; lat: number }, - zoom: number, -): TileAnchor => { - const selected = getSelectedGridSpec(zoom); - const { xtile, ytile } = lngLatToTileCoords(center.lat, center.lng, zoom); - // Snap to integer tile boundaries so the visual grid always aligns with the - // bbox that gets sent to the prediction API (which uses getSnappedAnchor). - return getSnappedAnchor( - clampAnchor({ - x: xtile - selected.columns / 2, - y: ytile - selected.rows / 2, - z: zoom, - }), - ); -}; +import { useTileGrid } from "@/features/try-fair/hooks/use-tile-grid"; +import { useGridDrag } from "@/features/try-fair/hooks/use-grid-drag"; +import { + useGridScreenGeometry, + screenLineToPointsAttr, +} from "@/features/try-fair/hooks/use-grid-screen-geometry"; -const getSelectedGridBBox = (anchor: TileAnchor): BBOX => { - const selected = getSelectedGridSpec(anchor.z); - const nw = num2deg(anchor.x, anchor.y, anchor.z); - const se = num2deg( - anchor.x + selected.columns, - anchor.y + selected.rows, - anchor.z, - ); - return [nw.lon_deg, se.lat_deg, se.lon_deg, nw.lat_deg]; -}; +// Constants -const getSnappedAnchor = (anchor: TileAnchor): TileAnchor => - clampAnchor({ - ...anchor, - x: Math.floor(anchor.x), - y: Math.floor(anchor.y), - }); +/** Grid line colour — extracted so it's easy to theme or adjust. */ +const GRID_LINE_COLOR = "#EF4444"; -/** Returns the tile zoom for the given resolution, falling back to MID. */ -const getTileZoomForResolution = (resolution?: TryFairResolution): number => - TRY_FAIR_RESOLUTION_ZOOM[resolution ?? TryFairResolution.MID]; +// Types -type GridScreenGeometry = { - verticalLines: { x1: number; y1: number; x2: number; y2: number }[]; - horizontalLines: { x1: number; y1: number; x2: number; y2: number }[]; - topRight: { x: number; y: number }; +type TryFairDraggableGridProps = { + map: Map | null; + mapContainerRef: RefObject; + onBBoxChange: (bbox: BBOX, tileZoom: number) => void; + /** Imagery center from tileJSON — snaps the grid here when it resolves. */ + center?: [number, number]; + /** Current resolution selection — triggers a re-center when it changes. */ + resolution?: TryFairResolution; + /** Selected model ID — triggers a re-center when the model changes. */ + modelId?: string | null; + /** When true, grid dragging is disabled. */ + isPredicting?: boolean; + /** Current predictions — when present, shows the export dropdown. */ + predictions?: GeoJSON.FeatureCollection | null; + /** Currently selected output type — used to name the export file. */ + outputType?: TryFairMapOutputType; }; -const toPointString = (line: { - x1: number; - y1: number; - x2: number; - y2: number; -}): string => `${line.x1},${line.y1} ${line.x2},${line.y2}`; - -// ── Export helper ───────────────────────────────────────────────────────────── +// ── Helpers ────────────────────────────────────────────────────────────────── -const exportPredictions = ( +const downloadPredictions = ( predictions: GeoJSON.FeatureCollection, outputType: TryFairMapOutputType, ) => { @@ -135,371 +58,133 @@ export const TryFairDraggableGrid = ({ map, mapContainerRef, onBBoxChange, - center, + center: imageryCenter, resolution, modelId, isPredicting = false, predictions, outputType, -}: { - map: Map | null; - mapContainerRef: RefObject; - onBBoxChange: (bbox: BBOX, tileZoom: number) => void; - /** Imagery center from tileJSON — snaps the grid here when it resolves */ - center?: [number, number]; - /** Current resolution selection — triggers a re-center when it changes */ - resolution?: TryFairResolution; - /** Selected model ID — triggers a re-center when the model changes */ - modelId?: string | null; - /** When true, grid dragging is disabled */ - isPredicting?: boolean; - /** Current predictions — when present, shows the export dropdown */ - predictions?: GeoJSON.FeatureCollection | null; - /** Currently selected output type — used to name the export file */ - outputType?: TryFairMapOutputType; -}) => { - const [anchor, setAnchor] = useState(null); - const [screenGeometry, setScreenGeometry] = - useState(null); - const [dragState, setDragState] = useState({ - isDragging: false, - startAnchor: null, - startTile: null, - dragPanWasEnabled: false, +}: TryFairDraggableGridProps) => { + // Grid anchor & bbox management + + const { anchor, setAnchor } = useTileGrid({ + map, + imageryCenter, + resolution, + modelId, + onBBoxChange, }); - const previousCenterRef = useRef<[number, number] | null>(null); - const isUserPositionedRef = useRef(false); - const pendingPointerRef = useRef<{ x: number; y: number } | null>(null); - const dragRafRef = useRef(null); - const hasPredictions = !!predictions && predictions.features.length > 0; + // Drag interaction - // Hide the export dropdown once the grid is moved away from the prediction area. - // Reset when fresh predictions arrive. + // Hide the export dropdown once the user drags the grid away from the + // area where the prediction was run. const [gridMovedSincePredict, setGridMovedSincePredict] = useState(false); - useEffect(() => { - if (hasPredictions) setGridMovedSincePredict(false); - }, [predictions]); // eslint-disable-line react-hooks/exhaustive-deps - - // Initialize / recenter grid from imagery center using the resolution tile zoom. - useEffect(() => { - if (!map) return; - const nextCenter = center - ? ([center[0], center[1]] as [number, number]) - : null; - const previousCenter = previousCenterRef.current; - const centerChanged = - !!nextCenter && - (!previousCenter || - previousCenter[0] !== nextCenter[0] || - previousCenter[1] !== nextCenter[1]); - if (!anchor || centerChanged) { - const target = nextCenter - ? { lng: nextCenter[0], lat: nextCenter[1] } - : map.getCenter(); - setAnchor( - getCenteredAnchor(target, getTileZoomForResolution(resolution)), - ); - isUserPositionedRef.current = false; - } - - previousCenterRef.current = nextCenter; - }, [map, center, anchor]); - - // Update the grid tile zoom when resolution changes. - useEffect(() => { - if (!map || resolution === undefined) return; - const newTileZoom = TRY_FAIR_RESOLUTION_ZOOM[resolution]; - const center = map.getCenter(); - isUserPositionedRef.current = false; - setAnchor( - getCenteredAnchor({ lng: center.lng, lat: center.lat }, newTileZoom), - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [resolution]); - - // Re-center the grid when the model changes. - useEffect(() => { - if (!map || !modelId) return; - isUserPositionedRef.current = false; - previousCenterRef.current = null; - setAnchor( - getCenteredAnchor(map.getCenter(), getTileZoomForResolution(resolution)), - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [modelId]); - - // Initialise the anchor once on map-load. - useEffect(() => { - if (!map) return; - setAnchor((prev) => { - if (prev) return prev; - const target = center - ? { lng: center[0], lat: center[1] } - : map.getCenter(); - return getCenteredAnchor(target, getTileZoomForResolution(resolution)); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [map]); - - useEffect(() => { - if (!anchor) return; - onBBoxChange(getSelectedGridBBox(getSnappedAnchor(anchor)), anchor.z); - }, [anchor, onBBoxChange]); - - const syncScreenGeometry = useCallback(() => { - if (!map || !anchor) return; - const selected = getSelectedGridSpec(anchor.z); - const verticalLines: GridScreenGeometry["verticalLines"] = []; - const horizontalLines: GridScreenGeometry["horizontalLines"] = []; - - for (let column = 0; column <= VISIBLE_GRID_COLUMNS; column++) { - const x = anchor.x + (column / VISIBLE_GRID_COLUMNS) * selected.columns; - const top = num2deg(x, anchor.y, anchor.z); - const bottom = num2deg(x, anchor.y + selected.rows, anchor.z); - const p1 = map.project({ - lng: top.lon_deg, - lat: top.lat_deg, - } as LngLatLike); - const p2 = map.project({ - lng: bottom.lon_deg, - lat: bottom.lat_deg, - } as LngLatLike); - verticalLines.push({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }); - } - - for (let row = 0; row <= VISIBLE_GRID_ROWS; row++) { - const y = anchor.y + (row / VISIBLE_GRID_ROWS) * selected.rows; - const left = num2deg(anchor.x, y, anchor.z); - const right = num2deg(anchor.x + selected.columns, y, anchor.z); - const p1 = map.project({ - lng: left.lon_deg, - lat: left.lat_deg, - } as LngLatLike); - const p2 = map.project({ - lng: right.lon_deg, - lat: right.lat_deg, - } as LngLatLike); - horizontalLines.push({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }); - } - - const topRight = verticalLines[VISIBLE_GRID_COLUMNS] - ? { - x: verticalLines[VISIBLE_GRID_COLUMNS].x1, - y: verticalLines[VISIBLE_GRID_COLUMNS].y1, - } - : { x: 0, y: 0 }; - - const hasInvalidPoint = [...verticalLines, ...horizontalLines].some( - (line) => - [line.x1, line.y1, line.x2, line.y2].some( - (value) => !Number.isFinite(value), - ), - ); - if (hasInvalidPoint) return; - - setScreenGeometry({ verticalLines, horizontalLines, topRight }); - }, [anchor, map]); - - useEffect(() => { - if (!map || !anchor) return; - syncScreenGeometry(); - map.on("move", syncScreenGeometry); - map.on("zoom", syncScreenGeometry); - const container = mapContainerRef.current; - let ro: ResizeObserver | null = null; - if (container && typeof ResizeObserver !== "undefined") { - ro = new ResizeObserver(syncScreenGeometry); - ro.observe(container); - } else { - window.addEventListener("resize", syncScreenGeometry); - } - return () => { - map.off("move", syncScreenGeometry); - map.off("zoom", syncScreenGeometry); - if (ro) ro.disconnect(); - else window.removeEventListener("resize", syncScreenGeometry); - }; - }, [anchor, map, mapContainerRef, syncScreenGeometry]); + const hasPredictions = !!predictions && predictions.features.length > 0; + // Reset the "moved" flag when fresh predictions arrive. useEffect(() => { - if ( - !dragState.isDragging || - !map || - !dragState.startAnchor || - !dragState.startTile - ) - return; - const { startAnchor, startTile } = dragState; - - const updateAnchorFromPointer = (clientX: number, clientY: number) => { - const container = mapContainerRef.current; - if (!container) return; - const rect = container.getBoundingClientRect(); - const lngLat = map.unproject([clientX - rect.left, clientY - rect.top]); - const { xtile, ytile } = lngLatToTileCoords( - lngLat.lat, - lngLat.lng, - startAnchor.z, - ); - const dx = xtile - startTile.x; - const dy = ytile - startTile.y; - setAnchor( - clampAnchor({ - x: startAnchor.x + dx, - y: startAnchor.y + dy, - z: startAnchor.z, - }), - ); - }; - - const flushPendingPointer = () => { - if (!pendingPointerRef.current) return; - const { x, y } = pendingPointerRef.current; - pendingPointerRef.current = null; - updateAnchorFromPointer(x, y); - }; - - const scheduleFrame = () => { - if (dragRafRef.current !== null) return; - dragRafRef.current = window.requestAnimationFrame(() => { - dragRafRef.current = null; - flushPendingPointer(); - if (pendingPointerRef.current) scheduleFrame(); - }); - }; + if (hasPredictions) setGridMovedSincePredict(false); + }, [hasPredictions]); + + const { isDragging, handlePointerDown } = useGridDrag({ + map, + mapContainerRef, + anchor, + setAnchor, + disabled: isPredicting, + onDragStart: () => setGridMovedSincePredict(true), + }); - const handlePointerMove = (e: PointerEvent) => { - pendingPointerRef.current = { x: e.clientX, y: e.clientY }; - scheduleFrame(); - }; + // Screen projection - const handlePointerUp = () => { - if (dragRafRef.current !== null) { - window.cancelAnimationFrame(dragRafRef.current); - dragRafRef.current = null; - } - flushPendingPointer(); - if ( - dragState.dragPanWasEnabled && - map.dragPan && - !map.dragPan.isEnabled() - ) { - map.dragPan.enable(); - } - setAnchor((prev) => (prev ? getSnappedAnchor(prev) : prev)); - setDragState((prev) => ({ - ...prev, - isDragging: false, - startAnchor: null, - startTile: null, - dragPanWasEnabled: false, - })); - }; + const screenGeometry = useGridScreenGeometry({ + map, + mapContainerRef, + anchor, + }); - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("pointerup", handlePointerUp); - return () => { - if (dragRafRef.current !== null) { - window.cancelAnimationFrame(dragRafRef.current); - dragRafRef.current = null; - } - pendingPointerRef.current = null; - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", handlePointerUp); - }; - }, [dragState, map, mapContainerRef]); + // Safe predictions ref for the export closure + + const predictionsRef = useRef(predictions); + predictionsRef.current = predictions; - // Generic drag start — works from any pointer-event source (SVG polygon, button, etc.) - const handleDragStart = (e: ReactPointerEvent) => { - if (!map || !anchor || isPredicting) return; - e.preventDefault(); - e.stopPropagation(); - const container = mapContainerRef.current; - if (!container) return; - const rect = container.getBoundingClientRect(); - const lngLat = map.unproject([e.clientX - rect.left, e.clientY - rect.top]); - const { xtile, ytile } = lngLatToTileCoords( - lngLat.lat, - lngLat.lng, - anchor.z, - ); - isUserPositionedRef.current = true; - setGridMovedSincePredict(true); - const dragPanWasEnabled = map.dragPan ? map.dragPan.isEnabled() : false; - if (dragPanWasEnabled) map.dragPan.disable(); - setDragState({ - isDragging: true, - startAnchor: anchor, - startTile: { x: xtile, y: ytile }, - dragPanWasEnabled, - }); - }; + // Render if (!screenGeometry) return null; - const { verticalLines, horizontalLines, topRight } = screenGeometry; + const { verticalLines, horizontalLines, exportButtonPosition } = + screenGeometry; - // Four corners of the grid boundary for the drag polygon - const polygonPoints = [ + // Four corners of the grid boundary for the transparent drag polygon. + const dragSurfacePoints = [ `${verticalLines[0].x1},${verticalLines[0].y1}`, `${verticalLines[VISIBLE_GRID_COLUMNS].x1},${verticalLines[VISIBLE_GRID_COLUMNS].y1}`, `${verticalLines[VISIBLE_GRID_COLUMNS].x2},${verticalLines[VISIBLE_GRID_COLUMNS].y2}`, `${verticalLines[0].x2},${verticalLines[0].y2}`, ].join(" "); - const cursorClass = isPredicting + const cursorStyle = isPredicting ? "cursor-not-allowed" - : dragState.isDragging + : isDragging ? "cursor-grabbing" : "cursor-grab"; + const showExportDropdown = + hasPredictions && !gridMovedSincePredict && outputType; + return (
      {/* Transparent drag surface covering the entire grid */} - {/* Grid lines */} - {verticalLines.map((line, index) => ( - - ))} - {horizontalLines.map((line, index) => ( - - ))} + {/* Vertical grid lines */} + {verticalLines.map((line, index) => { + const isBorderLine = + index === 0 || index === VISIBLE_GRID_COLUMNS; + return ( + + ); + })} + + {/* Horizontal grid lines */} + {horizontalLines.map((line, index) => { + const isBorderLine = index === 0 || index === VISIBLE_GRID_ROWS; + return ( + + ); + })} {/* Export dropdown — shown when results exist and grid hasn't moved */} - {hasPredictions && !gridMovedSincePredict && outputType && ( + {showExportDropdown && (
      @@ -519,7 +204,12 @@ export const TryFairDraggableGrid = ({ +); + +type SectionProps = { + title: string; + open: boolean; + onToggle: () => void; + children: ReactNode; +}; + +const Section = ({ title, open, onToggle, children }: SectionProps) => ( +
      + + {open ?
      {children}
      : null} +
      +); + +export const TryFairLayerControl = ({ + map, + hasActivePrediction, + hasTileServiceLayer, + predictionLayerIds, +}: TryFairLayerControlProps) => { + const [layersVisibility, setLayersVisibility] = useState({ + prediction: true, + imagery: true, + osm: true, + googleSatellite: false, + }); + const [sectionsOpen, setSectionsOpen] = useState({ + predictions: true, + imagery: true, + basemap: true, + }); + + const predictionLabel = "Prediction"; + const predictionSwatchClassName = "bg-[#A147D8]"; + + const setMapLayerVisibility = (layerId: string, visible: boolean) => { + if (!map?.isStyleLoaded()) return; + if (!map.getLayer(layerId)) return; + map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none"); + }; + + const togglePrediction = () => { + const nextValue = !layersVisibility.prediction; + predictionLayerIds.forEach((layerId) => { + setMapLayerVisibility(layerId, nextValue); + }); + setLayersVisibility((prev) => ({ ...prev, prediction: nextValue })); + }; + + const toggleImagery = () => { + const nextValue = !layersVisibility.imagery; + setMapLayerVisibility(TMS_LAYER_ID, nextValue); + setLayersVisibility((prev) => ({ ...prev, imagery: nextValue })); + }; + + const toggleBasemap = (layer: "osm" | "googleSatellite") => { + const nextValue = !layersVisibility[layer]; + const targetLayerId = + layer === "osm" + ? OSM_BASEMAP_LAYER_ID + : GOOGLE_SATELLITE_BASEMAP_LAYER_ID; + + setMapLayerVisibility(targetLayerId, nextValue); + setLayersVisibility((prev) => ({ ...prev, [layer]: nextValue })); + }; + + useEffect(() => { + if (!map) return; + + const applyVisibility = () => { + predictionLayerIds.forEach((layerId) => { + setMapLayerVisibility(layerId, layersVisibility.prediction); + }); + + if (hasTileServiceLayer) { + setMapLayerVisibility(TMS_LAYER_ID, layersVisibility.imagery); + } + + setMapLayerVisibility(OSM_BASEMAP_LAYER_ID, layersVisibility.osm); + setMapLayerVisibility( + GOOGLE_SATELLITE_BASEMAP_LAYER_ID, + layersVisibility.googleSatellite, + ); + }; + + applyVisibility(); + map.on("styledata", applyVisibility); + return () => { + map.off("styledata", applyVisibility); + }; + }, [map, predictionLayerIds, hasTileServiceLayer, layersVisibility]); + + return ( + + + +
      + } + > +
      +
      + +

      Layers

      +
      + + {hasActivePrediction ? ( +
      + setSectionsOpen((prev) => ({ + ...prev, + predictions: !prev.predictions, + })) + } + > +
      +
      + + {predictionLabel} +
      + +
      +
      + ) : null} + + {hasTileServiceLayer ? ( +
      + setSectionsOpen((prev) => ({ + ...prev, + imagery: !prev.imagery, + })) + } + > + } + onClick={toggleImagery} + /> +
      + ) : null} + +
      + setSectionsOpen((prev) => ({ ...prev, basemap: !prev.basemap })) + } + > + } + onClick={() => toggleBasemap("osm")} + /> + } + onClick={() => toggleBasemap("googleSatellite")} + /> +
      +
      + + + ); +}; 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 d3aee8153..14c97c43e 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 @@ -8,12 +8,9 @@ import { TryFairPredictionsLayer } from "@/features/try-fair/components/map/try- import { ChoroplethBucket } from "@/features/try-fair/utils/helpers"; import { TryFairChoroplethLegend } from "@/features/try-fair/components/map/chloropleth-legend"; import { TryFairPointsLegend } from "@/features/try-fair/components/map/points-legend"; -import { - LayerControl, - FitToBounds, - ZoomControls, -} from "@/components/map/controls"; +import { FitToBounds, ZoomControls } from "@/components/map/controls"; import { PREDICTION_LAYER_IDS } from "@/features/try-fair/utils/common"; +import { TryFairLayerControl } from "@/features/try-fair/components/map/try-fair-layer-control"; type TryFairMapProps = { map: Map | null; @@ -108,6 +105,7 @@ export const TryFairMap = ({ tileServiceURL={tileServiceValid ? tileServerURL : undefined} zoomControls={false} basemaps + showTileBoundaries onTileServiceFitToBounds={handleFitToGrid} /> @@ -136,13 +134,9 @@ export const TryFairMap = ({ {/* ── Right-side control strip ──────────────────────────────────── */} {map && ( -
      +
      {/* Zoom in / out */} - - - {/* Divider */} -
      - + {/* Zoom to grid */} {/* Layers panel */} -
      )} diff --git a/frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx b/frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx index 21afe034c..a202da588 100644 --- a/frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx +++ b/frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx @@ -122,7 +122,7 @@ export const TryFairPredictionsLayer = ({ type: "circle", source: SOURCE_ID, paint: { - "circle-radius": 2, + "circle-radius": 4, "circle-color": "#A147D8", "circle-stroke-color": "#A147D8", "circle-stroke-width": 1, diff --git a/frontend/src/features/try-fair/hooks/use-grid-drag.ts b/frontend/src/features/try-fair/hooks/use-grid-drag.ts new file mode 100644 index 000000000..c0cabc1f6 --- /dev/null +++ b/frontend/src/features/try-fair/hooks/use-grid-drag.ts @@ -0,0 +1,221 @@ +import { + PointerEvent as ReactPointerEvent, + RefObject, + useEffect, + useRef, + useState, +} from "react"; +import { Map } from "maplibre-gl"; +import { + TileAnchor, + clampAnchorToWorldBounds, + lngLatToTileCoords, + snapAnchorToTileBoundary, +} from "@/features/try-fair/utils/tile-math"; + +// ── Types + +type UseGridDragOptions = { + map: Map | null; + mapContainerRef: RefObject; + /** Current grid anchor — read at drag-start and updated during drags. */ + anchor: TileAnchor | null; + /** State setter for the anchor — called during drags and on snap at release. */ + setAnchor: React.Dispatch>; + /** When true, dragging is disabled (e.g. prediction in progress). */ + disabled?: boolean; + /** Fired once when a drag begins (e.g. to hide the export dropdown). */ + onDragStart?: () => void; +}; + +type UseGridDragReturn = { + /** Whether the user is currently dragging the grid. */ + isDragging: boolean; + /** Attach this to the drag surface's `onPointerDown`. */ + handlePointerDown: (e: ReactPointerEvent) => void; +}; + +// ── Hook + +/** + * Handles pointer-based dragging of the tile grid overlay. + * + * Key design decisions: + * - Mutable drag state (start anchor, start tile coords, saved dragPan + * state) is stored in a **ref** rather than `useState`. This avoids the + * old issue where the entire pointermove/pointerup effect re-subscribed + * on every state change. + * - Only the `isDragging` boolean is React state so the component can + * toggle cursor styles. + * - Pointer-move updates are throttled via `requestAnimationFrame`. + */ +export const useGridDrag = ({ + map, + mapContainerRef, + anchor, + setAnchor, + disabled = false, + onDragStart, +}: UseGridDragOptions): UseGridDragReturn => { + const [isDragging, setIsDragging] = useState(false); + + // Mutable refs for drag state — avoids effect re-subscription on every update. + const dragStartAnchorRef = useRef(null); + const dragStartTileRef = useRef<{ x: number; y: number } | null>(null); + const dragPanWasEnabledRef = useRef(false); + + // rAF-throttled pointer tracking. + const pendingPointerRef = useRef<{ x: number; y: number } | null>(null); + const animationFrameIdRef = useRef(null); + + // Keep fresh references for values needed inside window event handlers. + const mapRef = useRef(map); + mapRef.current = map; + const containerRef = useRef(mapContainerRef); + containerRef.current = mapContainerRef; + const setAnchorRef = useRef(setAnchor); + setAnchorRef.current = setAnchor; + + // ── Pointer-down: start a drag ───────────────────────────────────────── + + const handlePointerDown = (e: ReactPointerEvent) => { + if (!map || !anchor || disabled) return; + + e.preventDefault(); + e.stopPropagation(); + + const container = mapContainerRef.current; + if (!container) return; + + const containerRect = container.getBoundingClientRect(); + const lngLat = map.unproject([ + e.clientX - containerRect.left, + e.clientY - containerRect.top, + ]); + const { tileX, tileY } = lngLatToTileCoords( + lngLat.lat, + lngLat.lng, + anchor.z, + ); + + // Save drag-start state in refs. + dragStartAnchorRef.current = anchor; + dragStartTileRef.current = { x: tileX, y: tileY }; + + // Disable map drag-panning while we own the pointer. + const wasDragPanEnabled = map.dragPan ? map.dragPan.isEnabled() : false; + dragPanWasEnabledRef.current = wasDragPanEnabled; + if (wasDragPanEnabled) map.dragPan.disable(); + + setIsDragging(true); + onDragStart?.(); + }; + + // ── Pointer-move / pointer-up (attached to window while dragging) ────── + + useEffect(() => { + if (!isDragging) return; + + const computeAnchorFromPointer = (clientX: number, clientY: number) => { + const currentMap = mapRef.current; + const container = containerRef.current.current; + const startAnchor = dragStartAnchorRef.current; + const startTile = dragStartTileRef.current; + if (!currentMap || !container || !startAnchor || !startTile) return; + + const containerRect = container.getBoundingClientRect(); + const lngLat = currentMap.unproject([ + clientX - containerRect.left, + clientY - containerRect.top, + ]); + const { tileX, tileY } = lngLatToTileCoords( + lngLat.lat, + lngLat.lng, + startAnchor.z, + ); + + const tileDeltaX = tileX - startTile.x; + const tileDeltaY = tileY - startTile.y; + + setAnchorRef.current( + clampAnchorToWorldBounds({ + x: startAnchor.x + tileDeltaX, + y: startAnchor.y + tileDeltaY, + z: startAnchor.z, + }), + ); + }; + + const flushPendingPointer = () => { + const pending = pendingPointerRef.current; + if (!pending) return; + pendingPointerRef.current = null; + computeAnchorFromPointer(pending.x, pending.y); + }; + + const scheduleAnimationFrame = () => { + if (animationFrameIdRef.current !== null) return; + animationFrameIdRef.current = window.requestAnimationFrame(() => { + animationFrameIdRef.current = null; + flushPendingPointer(); + // If another pointer event arrived during the frame, schedule again. + if (pendingPointerRef.current) scheduleAnimationFrame(); + }); + }; + + const onPointerMove = (e: PointerEvent) => { + pendingPointerRef.current = { x: e.clientX, y: e.clientY }; + scheduleAnimationFrame(); + }; + + const onPointerUp = () => { + // Cancel any pending frame. + if (animationFrameIdRef.current !== null) { + window.cancelAnimationFrame(animationFrameIdRef.current); + animationFrameIdRef.current = null; + } + + // Apply final position. + flushPendingPointer(); + + // Restore map drag-panning. + const currentMap = mapRef.current; + if ( + dragPanWasEnabledRef.current && + currentMap?.dragPan && + !currentMap.dragPan.isEnabled() + ) { + currentMap.dragPan.enable(); + } + + // Snap anchor to the nearest integer tile boundary on release. + setAnchorRef.current((prev) => + prev ? snapAnchorToTileBoundary(prev) : prev, + ); + + // Reset drag state. + dragStartAnchorRef.current = null; + dragStartTileRef.current = null; + dragPanWasEnabledRef.current = false; + setIsDragging(false); + }; + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + + return () => { + if (animationFrameIdRef.current !== null) { + window.cancelAnimationFrame(animationFrameIdRef.current); + animationFrameIdRef.current = null; + } + pendingPointerRef.current = null; + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + }; + // `isDragging` is the sole dependency — the effect subscribes when + // dragging starts and unsubscribes when it ends. All other values are + // read from refs to avoid re-subscription churn. + }, [isDragging]); + + return { isDragging, handlePointerDown }; +}; diff --git a/frontend/src/features/try-fair/hooks/use-grid-screen-geometry.ts b/frontend/src/features/try-fair/hooks/use-grid-screen-geometry.ts new file mode 100644 index 000000000..db2f3c71b --- /dev/null +++ b/frontend/src/features/try-fair/hooks/use-grid-screen-geometry.ts @@ -0,0 +1,196 @@ +import { RefObject, useCallback, useEffect, useRef, useState } from "react"; +import { Map, LngLatLike } from "maplibre-gl"; +import { num2deg } from "@/utils/geo/geometry-utils"; +import { + VISIBLE_GRID_COLUMNS, + VISIBLE_GRID_ROWS, +} from "@/features/try-fair/utils/common"; +import { + TileAnchor, + getSelectedGridSpec, +} from "@/features/try-fair/utils/tile-math"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type ScreenLine = { x1: number; y1: number; x2: number; y2: number }; + +export type GridScreenGeometry = { + verticalLines: ScreenLine[]; + horizontalLines: ScreenLine[]; + /** Top-right corner of the grid — used for positioning the export button. */ + exportButtonPosition: { x: number; y: number }; +}; + +type UseGridScreenGeometryOptions = { + map: Map | null; + mapContainerRef: RefObject; + anchor: TileAnchor | null; +}; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** Format a line as an SVG polyline `points` attribute value. */ +export const screenLineToPointsAttr = (line: ScreenLine): string => + `${line.x1},${line.y1} ${line.x2},${line.y2}`; + +/** Returns true if any coordinate in the line set is non-finite (NaN / ±Infinity). */ +const hasInvalidCoordinates = (lines: ScreenLine[]): boolean => + lines.some((line) => + [line.x1, line.y1, line.x2, line.y2].some( + (value) => !Number.isFinite(value), + ), + ); + +// ── Hook ───────────────────────────────────────────────────────────────────── + +/** + * Projects the tile-space grid onto screen pixels via `map.project()`. + * + * Performance improvement: map `move`/`zoom` events are throttled via + * `requestAnimationFrame` so we update at most once per frame instead of + * on every event (which can fire at 60fps during panning). + */ +export const useGridScreenGeometry = ({ + map, + mapContainerRef, + anchor, +}: UseGridScreenGeometryOptions): GridScreenGeometry | null => { + const [geometry, setGeometry] = useState(null); + const rafIdRef = useRef(null); + + // ── Projection logic ─────────────────────────────────────────────────── + + const projectGridToScreen = useCallback(() => { + if (!map || !anchor) return; + + const gridSpec = getSelectedGridSpec(anchor.z); + const verticalLines: ScreenLine[] = []; + const horizontalLines: ScreenLine[] = []; + + // Vertical lines: one per visible column boundary. + for (let col = 0; col <= VISIBLE_GRID_COLUMNS; col++) { + const tileX = + anchor.x + (col / VISIBLE_GRID_COLUMNS) * gridSpec.columns; + + const topCorner = num2deg(tileX, anchor.y, anchor.z); + const bottomCorner = num2deg( + tileX, + anchor.y + gridSpec.rows, + anchor.z, + ); + + const topPixel = map.project({ + lng: topCorner.lon_deg, + lat: topCorner.lat_deg, + } as LngLatLike); + const bottomPixel = map.project({ + lng: bottomCorner.lon_deg, + lat: bottomCorner.lat_deg, + } as LngLatLike); + + verticalLines.push({ + x1: topPixel.x, + y1: topPixel.y, + x2: bottomPixel.x, + y2: bottomPixel.y, + }); + } + + // Horizontal lines: one per visible row boundary. + for (let row = 0; row <= VISIBLE_GRID_ROWS; row++) { + const tileY = + anchor.y + (row / VISIBLE_GRID_ROWS) * gridSpec.rows; + + const leftCorner = num2deg(anchor.x, tileY, anchor.z); + const rightCorner = num2deg( + anchor.x + gridSpec.columns, + tileY, + anchor.z, + ); + + const leftPixel = map.project({ + lng: leftCorner.lon_deg, + lat: leftCorner.lat_deg, + } as LngLatLike); + const rightPixel = map.project({ + lng: rightCorner.lon_deg, + lat: rightCorner.lat_deg, + } as LngLatLike); + + horizontalLines.push({ + x1: leftPixel.x, + y1: leftPixel.y, + x2: rightPixel.x, + y2: rightPixel.y, + }); + } + + // Guard against projections that produce NaN (e.g. when the grid is + // entirely outside the current viewport). + if ( + hasInvalidCoordinates(verticalLines) || + hasInvalidCoordinates(horizontalLines) + ) { + return; + } + + // The export button sits at the top-right corner of the grid. + const lastVerticalLine = verticalLines[VISIBLE_GRID_COLUMNS]; + const exportButtonPosition = lastVerticalLine + ? { x: lastVerticalLine.x1, y: lastVerticalLine.y1 } + : { x: 0, y: 0 }; + + setGeometry({ verticalLines, horizontalLines, exportButtonPosition }); + }, [anchor, map]); + + // ── Subscribe to map movements and container resizes ─────────────────── + + useEffect(() => { + if (!map || !anchor) return; + + // Initial projection. + projectGridToScreen(); + + // Throttled handler: instead of re-rendering on every move event, we + // batch to one update per animation frame. + const scheduleProjection = () => { + if (rafIdRef.current !== null) return; + rafIdRef.current = window.requestAnimationFrame(() => { + rafIdRef.current = null; + projectGridToScreen(); + }); + }; + + map.on("move", scheduleProjection); + map.on("zoom", scheduleProjection); + + // Listen for container resizes. + const container = mapContainerRef.current; + let resizeObserver: ResizeObserver | null = null; + + if (container && typeof ResizeObserver !== "undefined") { + resizeObserver = new ResizeObserver(scheduleProjection); + resizeObserver.observe(container); + } else { + window.addEventListener("resize", scheduleProjection); + } + + return () => { + map.off("move", scheduleProjection); + map.off("zoom", scheduleProjection); + + if (rafIdRef.current !== null) { + window.cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } + + if (resizeObserver) { + resizeObserver.disconnect(); + } else { + window.removeEventListener("resize", scheduleProjection); + } + }; + }, [anchor, map, mapContainerRef, projectGridToScreen]); + + return geometry; +}; diff --git a/frontend/src/features/try-fair/hooks/use-tile-grid.ts b/frontend/src/features/try-fair/hooks/use-tile-grid.ts new file mode 100644 index 000000000..bd4350527 --- /dev/null +++ b/frontend/src/features/try-fair/hooks/use-tile-grid.ts @@ -0,0 +1,173 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Map } from "maplibre-gl"; +import { BBOX } from "@/types"; +import { TryFairResolution } from "@/enums/try-fair"; +import { + TileAnchor, + computeCenteredAnchor, + computeGridBBox, + getTileZoomForResolution, + snapAnchorToTileBoundary, +} from "@/features/try-fair/utils/tile-math"; + +// ── Types + +type UseTileGridOptions = { + map: Map | null; + /** Imagery center [lng, lat] — snaps the grid here when it resolves. */ + imageryCenter?: [number, number]; + /** Current resolution selection. */ + resolution?: TryFairResolution; + /** Selected model ID — triggers a re-center when it changes. */ + modelId?: string | null; + /** Callback fired whenever the snapped grid bbox changes. */ + onBBoxChange: (bbox: BBOX, tileZoom: number) => void; +}; + +type UseTileGridReturn = { + anchor: TileAnchor | null; + setAnchor: React.Dispatch>; + tileZoom: number; +}; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** Returns true if two [lng, lat] tuples differ. */ +const centersAreDifferent = ( + a: [number, number] | null, + b: [number, number] | null, +): boolean => { + if (a === null || b === null) return a !== b; + return a[0] !== b[0] || a[1] !== b[1]; +}; + +/** Returns true if two BBOX arrays are element-wise equal. */ +const bboxesAreEqual = (a: BBOX, b: BBOX): boolean => + a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]; + +// ── Hook ───────────────────────────────────────────────────────────────────── + +/** + * Manages the tile-grid anchor and notifies the parent when the snapped + * bounding box changes. + * + * Consolidates the four separate recentering effects from the original + * implementation into a single effect with explicit priority: + * + * 1. Model changed → recenter (new imagery) + * 2. Resolution changed → recenter (grid size changes) + * 3. Imagery center changed → recenter on new center + * 4. No anchor yet → initialize + */ +export const useTileGrid = ({ + map, + imageryCenter, + resolution, + modelId, + onBBoxChange, +}: UseTileGridOptions): UseTileGridReturn => { + const [anchor, setAnchor] = useState(null); + const tileZoom = getTileZoomForResolution(resolution); + + // ── Track previous values to detect what changed ───────────────────────── + + const previousModelIdRef = useRef(modelId); + const previousResolutionRef = useRef( + resolution, + ); + const previousImageryCenterRef = useRef<[number, number] | undefined>( + imageryCenter, + ); + + // Consolidated recentering effect + useEffect(() => { + if (!map) return; + + const modelChanged = modelId !== previousModelIdRef.current; + const resolutionChanged = resolution !== previousResolutionRef.current; + const imageryCenterChanged = centersAreDifferent( + imageryCenter ?? null, + previousImageryCenterRef.current ?? null, + ); + + // Update refs for next comparison. + previousModelIdRef.current = modelId; + previousResolutionRef.current = resolution; + previousImageryCenterRef.current = imageryCenter; + + // Model changed — recenter from map center + if (modelChanged && modelId) { + const mapCenter = map.getCenter(); + setAnchor( + computeCenteredAnchor( + { lng: mapCenter.lng, lat: mapCenter.lat }, + tileZoom, + ), + ); + return; + } + + // the grid footprint changes, so recenter from the current map center. + if (resolutionChanged) { + const mapCenter = map.getCenter(); + setAnchor( + computeCenteredAnchor( + { lng: mapCenter.lng, lat: mapCenter.lat }, + tileZoom, + ), + ); + return; + } + + // Imagery center resolved/changed — recenter on it. + if (imageryCenterChanged && imageryCenter) { + setAnchor( + computeCenteredAnchor( + { lng: imageryCenter[0], lat: imageryCenter[1] }, + tileZoom, + ), + ); + return; + } + + // Priority 4: No anchor yet (first render) — initialize. + if (!anchor) { + const target = imageryCenter + ? { lng: imageryCenter[0], lat: imageryCenter[1] } + : map.getCenter(); + setAnchor(computeCenteredAnchor(target, tileZoom)); + } + // `anchor` is intentionally excluded from deps — we only want to + // initialise when there's no anchor. Subsequent anchor updates are + // driven by dragging, not by this effect. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map, modelId, resolution, imageryCenter, tileZoom]); + + // ── Notify parent when the *snapped* bbox changes ──────────────────────── + // + // The old code called onBBoxChange on every fractional anchor update during + // drags. We now compare the snapped bbox and only fire when it actually + // changes, avoiding expensive upstream work on every pixel of movement. + + const previousBBoxRef = useRef(null); + + const stableOnBBoxChange = useCallback(onBBoxChange, [onBBoxChange]); + + useEffect(() => { + if (!anchor) return; + + const snappedAnchor = snapAnchorToTileBoundary(anchor); + const currentBBox = computeGridBBox(snappedAnchor); + + const bboxChanged = + !previousBBoxRef.current || + !bboxesAreEqual(previousBBoxRef.current, currentBBox); + + if (bboxChanged) { + previousBBoxRef.current = currentBBox; + stableOnBBoxChange(currentBBox, anchor.z); + } + }, [anchor, stableOnBBoxChange]); + + return { anchor, setAnchor, tileZoom }; +}; 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 e94f4f464..85024296a 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 @@ -19,7 +19,7 @@ export const useTryFairParams = () => { model: parseAsString.withDefault("unet-segmentation"), output: parseAsString.withDefault(TryFairMapOutputType.POINTS), resolution: parseAsString.withDefault(TryFairResolution.MID), - confidence: parseAsFloat.withDefault(0.5), + confidence: parseAsFloat.withDefault(0.7), }, { history: "replace" }, ); diff --git a/frontend/src/features/try-fair/utils/common.tsx b/frontend/src/features/try-fair/utils/common.tsx index eb073695c..19d9e0596 100644 --- a/frontend/src/features/try-fair/utils/common.tsx +++ b/frontend/src/features/try-fair/utils/common.tsx @@ -13,7 +13,7 @@ export const RESOLUTIONS: { { value: TryFairResolution.LOW, label: TRY_FAIR_PAGE_CONTENT.sidebar.parameters.resolution.low, - size: 12, + size: 18, }, { value: TryFairResolution.MID, @@ -23,7 +23,7 @@ export const RESOLUTIONS: { { value: TryFairResolution.HIGH, label: TRY_FAIR_PAGE_CONTENT.sidebar.parameters.resolution.high, - size: 18, + size: 12, }, ]; @@ -50,9 +50,9 @@ export const OUTPUT_TYPES: { ]; export const TRY_FAIR_RESOLUTION_ZOOM: Record = { - [TryFairResolution.LOW]: 17, - [TryFairResolution.MID]: 18, - [TryFairResolution.HIGH]: 19, + [TryFairResolution.LOW]: 18, + [TryFairResolution.MID]: 19, + [TryFairResolution.HIGH]: 20, }; // Prediction layer IDs (kept in sync with try-fair-prediction-results.tsx) @@ -86,9 +86,9 @@ export const SELECTED_GRID_BY_ZOOM: Record = { 20: { columns: 3, rows: 3 }, }; -export const BASE_GRID_ZOOM = 17; +export const BASE_GRID_ZOOM = 18; export const MIN_GRID_ZOOM = BASE_GRID_ZOOM; -export const MAX_GRID_ZOOM = 22; +export const MAX_GRID_ZOOM = 21; type GridSpec = { columns: number; rows: number }; const DEFAULT_GRID_SPEC: GridSpec = { columns: 2, rows: 2 }; diff --git a/frontend/src/features/try-fair/utils/tile-math.ts b/frontend/src/features/try-fair/utils/tile-math.ts new file mode 100644 index 000000000..c95d98c66 --- /dev/null +++ b/frontend/src/features/try-fair/utils/tile-math.ts @@ -0,0 +1,117 @@ +import { num2deg } from "@/utils/geo/geometry-utils"; +import { BBOX } from "@/types"; +import { + DEFAULT_SELECTED_GRID, + SELECTED_GRID_BY_ZOOM, + SelectedGridSpec, + TRY_FAIR_RESOLUTION_ZOOM, +} from "@/features/try-fair/utils/common"; +import { TryFairResolution } from "@/enums/try-fair"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +/** A tile-space position: integer/fractional x/y at a given tile zoom level. */ +export type TileAnchor = { x: number; y: number; z: number }; + +// ── Pure helpers ───────────────────────────────────────────────────────────── + +/** Clamp `value` to the range [`min`, `max`]. */ +export const clamp = (value: number, min: number, max: number): number => + Math.min(Math.max(value, min), max); + +/** + * Look up the selected-grid spec (columns × rows sent to the prediction API) + * for the given tile zoom level, falling back to the default spec. + */ +export const getSelectedGridSpec = (tileZoom: number): SelectedGridSpec => + SELECTED_GRID_BY_ZOOM[tileZoom] ?? DEFAULT_SELECTED_GRID; + +/** + * Convert a geographic lng/lat to fractional tile coordinates at `tileZoom`. + * + * Unlike `deg2num` in geometry-utils (which floors to integers), this returns + * the raw fractional position so the grid can be positioned precisely. + */ +export const lngLatToTileCoords = ( + latitudeDeg: number, + longitudeDeg: number, + tileZoom: number, +): { tileX: number; tileY: number } => { + const latitudeRad = (latitudeDeg * Math.PI) / 180; + const tileCount = Math.pow(2, tileZoom); + return { + tileX: ((longitudeDeg + 180) / 360) * tileCount, + tileY: + ((1 - Math.asinh(Math.tan(latitudeRad)) / Math.PI) / 2) * tileCount, + }; +}; + +/** + * Clamp an anchor so the selected grid stays within the world tile boundaries. + * + * At zoom z, valid tile indices are [0, 2^z). The selected grid occupies + * `columns × rows` tiles, so the anchor must stay within + * [0, 2^z − columns] × [0, 2^z − rows]. + */ +export const clampAnchorToWorldBounds = (anchor: TileAnchor): TileAnchor => { + const gridSpec = getSelectedGridSpec(anchor.z); + const maxTileIndex = Math.pow(2, anchor.z); + const maxAnchorX = Math.max(0, maxTileIndex - gridSpec.columns); + const maxAnchorY = Math.max(0, maxTileIndex - gridSpec.rows); + return { + ...anchor, + x: clamp(anchor.x, 0, maxAnchorX), + y: clamp(anchor.y, 0, maxAnchorY), + }; +}; + +/** + * Snap anchor x/y down to the nearest integer tile boundary. + * This aligns the visual grid with the bbox sent to the prediction API. + */ +export const snapAnchorToTileBoundary = (anchor: TileAnchor): TileAnchor => + clampAnchorToWorldBounds({ + ...anchor, + x: Math.floor(anchor.x), + y: Math.floor(anchor.y), + }); + +/** + * Compute an anchor that centers the selected grid around the given lng/lat. + * The result is snapped to integer tile boundaries. + */ +export const computeCenteredAnchor = ( + center: { lng: number; lat: number }, + tileZoom: number, +): TileAnchor => { + const gridSpec = getSelectedGridSpec(tileZoom); + const { tileX, tileY } = lngLatToTileCoords(center.lat, center.lng, tileZoom); + + return snapAnchorToTileBoundary( + clampAnchorToWorldBounds({ + x: tileX - gridSpec.columns / 2, + y: tileY - gridSpec.rows / 2, + z: tileZoom, + }), + ); +}; + +/** + * Compute the [west, south, east, north] bounding box for the selected grid + * area starting at `anchor`. + */ +export const computeGridBBox = (anchor: TileAnchor): BBOX => { + const gridSpec = getSelectedGridSpec(anchor.z); + const northWest = num2deg(anchor.x, anchor.y, anchor.z); + const southEast = num2deg( + anchor.x + gridSpec.columns, + anchor.y + gridSpec.rows, + anchor.z, + ); + return [northWest.lon_deg, southEast.lat_deg, southEast.lon_deg, northWest.lat_deg]; +}; + +/** Map a resolution enum to its corresponding tile zoom level. Defaults to MID. */ +export const getTileZoomForResolution = ( + resolution?: TryFairResolution, +): number => TRY_FAIR_RESOLUTION_ZOOM[resolution ?? TryFairResolution.MID]; From e790f6518416d0b2dc291a43d488923e6f7e30bb Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Thu, 28 May 2026 15:04:10 +0100 Subject: [PATCH 52/62] feat: implement new layer actions --- .../components/map/controls/zoom-control.tsx | 14 +++++++++++-- .../components/map/draggable-grid.tsx | 20 +++++++++---------- .../components/map/try-fair-layer-control.tsx | 14 +++---------- .../try-fair/components/map/try-fair-map.tsx | 6 +++--- .../map/try-fair-prediction-results.tsx | 5 ++--- .../features/try-fair/hooks/use-grid-drag.ts | 5 +---- .../hooks/use-grid-screen-geometry.ts | 18 ++++------------- .../features/try-fair/hooks/use-tile-grid.ts | 2 +- .../src/features/try-fair/utils/tile-math.ts | 10 +++++++--- 9 files changed, 43 insertions(+), 51 deletions(-) diff --git a/frontend/src/components/map/controls/zoom-control.tsx b/frontend/src/components/map/controls/zoom-control.tsx index b3372a6c7..00c3cb9a7 100644 --- a/frontend/src/components/map/controls/zoom-control.tsx +++ b/frontend/src/components/map/controls/zoom-control.tsx @@ -16,7 +16,11 @@ const ZoomButton = ({ icon: string; rounded?: boolean; }) => ( - ); -export const ZoomControls = ({ map, rounded }: { map: Map | null; rounded?: boolean }) => { +export const ZoomControls = ({ + map, + rounded, +}: { + map: Map | null; + rounded?: boolean; +}) => { const currentZoom = useMapStore((state) => state.zoom); const handleZoomIn = useCallback(() => { diff --git a/frontend/src/features/try-fair/components/map/draggable-grid.tsx b/frontend/src/features/try-fair/components/map/draggable-grid.tsx index 94c35305a..525f56fe9 100644 --- a/frontend/src/features/try-fair/components/map/draggable-grid.tsx +++ b/frontend/src/features/try-fair/components/map/draggable-grid.tsx @@ -18,12 +18,12 @@ import { screenLineToPointsAttr, } from "@/features/try-fair/hooks/use-grid-screen-geometry"; -// Constants +// Constants /** Grid line colour — extracted so it's easy to theme or adjust. */ const GRID_LINE_COLOR = "#EF4444"; -// Types +// Types type TryFairDraggableGridProps = { map: Map | null; @@ -65,7 +65,7 @@ export const TryFairDraggableGrid = ({ predictions, outputType, }: TryFairDraggableGridProps) => { - // Grid anchor & bbox management + // Grid anchor & bbox management const { anchor, setAnchor } = useTileGrid({ map, @@ -75,11 +75,12 @@ export const TryFairDraggableGrid = ({ onBBoxChange, }); - // Drag interaction + // Drag interaction // Hide the export dropdown once the user drags the grid away from the // area where the prediction was run. - const [gridMovedSincePredict, setGridMovedSincePredict] = useState(false); + const [gridMovedSincePredict, setGridMovedSincePredict] = + useState(false); const hasPredictions = !!predictions && predictions.features.length > 0; @@ -97,7 +98,7 @@ export const TryFairDraggableGrid = ({ onDragStart: () => setGridMovedSincePredict(true), }); - // Screen projection + // Screen projection const screenGeometry = useGridScreenGeometry({ map, @@ -106,8 +107,8 @@ export const TryFairDraggableGrid = ({ }); // Safe predictions ref for the export closure - - const predictionsRef = useRef(predictions); + + const predictionsRef = useRef(predictions); predictionsRef.current = predictions; // Render @@ -148,8 +149,7 @@ export const TryFairDraggableGrid = ({ {/* Vertical grid lines */} {verticalLines.map((line, index) => { - const isBorderLine = - index === 0 || index === VISIBLE_GRID_COLUMNS; + const isBorderLine = index === 0 || index === VISIBLE_GRID_COLUMNS; return (
      -

      Layers

      @@ -178,15 +177,10 @@ export const TryFairLayerControl = ({ >
      -
      +
      h?.disable()); + handlers.forEach((handler) => handler?.disable()); } else { - handlers.forEach((h) => h?.enable()); + handlers.forEach((handler) => handler?.enable()); } return () => { // Always re-enable on unmount/cleanup to avoid getting stuck - handlers.forEach((h) => h?.enable()); + handlers.forEach((handler) => handler?.enable()); }; }, [map, isPredicting]); diff --git a/frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx b/frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx index a202da588..3574d5ae1 100644 --- a/frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx +++ b/frontend/src/features/try-fair/components/map/try-fair-prediction-results.tsx @@ -219,15 +219,14 @@ export const TryFairPredictionsLayer = ({ > {/* Offset so the tooltip doesn't sit directly under the cursor */}
      -
      -

      +

      +

      Buildings detected

      {tooltip.count.toLocaleString()}

      - {/* Arrow pointing left toward cursor */}
      { anchor.y + gridSpec.rows, anchor.z, ); - return [northWest.lon_deg, southEast.lat_deg, southEast.lon_deg, northWest.lat_deg]; + return [ + northWest.lon_deg, + southEast.lat_deg, + southEast.lon_deg, + northWest.lat_deg, + ]; }; /** Map a resolution enum to its corresponding tile zoom level. Defaults to MID. */ From 1fc6a9a01d66baf9d5d2e4e231dc082ae25d8d91 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Thu, 28 May 2026 15:15:39 +0100 Subject: [PATCH 53/62] fix: fix layer control icon on mobile --- .../try-fair/components/map/try-fair-layer-control.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/try-fair/components/map/try-fair-layer-control.tsx b/frontend/src/features/try-fair/components/map/try-fair-layer-control.tsx index 8e63c2c51..e2c6ef3c9 100644 --- a/frontend/src/features/try-fair/components/map/try-fair-layer-control.tsx +++ b/frontend/src/features/try-fair/components/map/try-fair-layer-control.tsx @@ -154,8 +154,8 @@ export const TryFairLayerControl = ({ disableCheveronIcon distance={10} triggerComponent={ -
      - +
      +
      } > From 2e0a52b71f75230dda54db29cc193cff6d1fb973 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Thu, 28 May 2026 15:31:57 +0100 Subject: [PATCH 54/62] fix: fix chloropleth label not showing inside grid --- .../components/map/draggable-grid.tsx | 68 +++++++++++++++++++ .../components/map/try-fair-layer-control.tsx | 2 +- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/try-fair/components/map/draggable-grid.tsx b/frontend/src/features/try-fair/components/map/draggable-grid.tsx index 525f56fe9..a637e9f5c 100644 --- a/frontend/src/features/try-fair/components/map/draggable-grid.tsx +++ b/frontend/src/features/try-fair/components/map/draggable-grid.tsx @@ -45,6 +45,8 @@ type TryFairDraggableGridProps = { // ── Helpers ────────────────────────────────────────────────────────────────── +const CHOROPLETH_FILL_LAYER_ID = "try-fair-predictions-choropleth-fill"; + const downloadPredictions = ( predictions: GeoJSON.FeatureCollection, outputType: TryFairMapOutputType, @@ -52,6 +54,12 @@ const downloadPredictions = ( geoJSONDowloader(predictions, `fair-predictions-${outputType.toLowerCase()}`); }; +type HoverTooltip = { + x: number; + y: number; + count: number; +} | null; + // ── Component ──────────────────────────────────────────────────────────────── export const TryFairDraggableGrid = ({ @@ -81,6 +89,7 @@ export const TryFairDraggableGrid = ({ // area where the prediction was run. const [gridMovedSincePredict, setGridMovedSincePredict] = useState(false); + const [hoverTooltip, setHoverTooltip] = useState(null); const hasPredictions = !!predictions && predictions.features.length > 0; @@ -134,6 +143,34 @@ export const TryFairDraggableGrid = ({ const showExportDropdown = hasPredictions && !gridMovedSincePredict && outputType; + const isChoroplethOutput = outputType === TryFairMapOutputType.CLUSTER; + + const handleDragSurfacePointerMove = ( + e: React.PointerEvent, + ) => { + if (!map || !isChoroplethOutput) { + setHoverTooltip(null); + return; + } + + const canvasRect = map.getCanvas().getBoundingClientRect(); + const point = { + x: e.clientX - canvasRect.left, + y: e.clientY - canvasRect.top, + }; + const queryPoint: [number, number] = [point.x, point.y]; + + const features = map.queryRenderedFeatures(queryPoint, { + layers: [CHOROPLETH_FILL_LAYER_ID], + }); + if (!features.length) { + setHoverTooltip(null); + return; + } + + const count = Number(features[0].properties?.count ?? 0); + setHoverTooltip({ x: point.x, y: point.y, count }); + }; return (
      @@ -145,6 +182,8 @@ export const TryFairDraggableGrid = ({ className={`pointer-events-auto ${cursorStyle}`} style={{ touchAction: "none" }} onPointerDown={handlePointerDown} + onPointerMove={handleDragSurfacePointerMove} + onPointerLeave={() => setHoverTooltip(null)} /> {/* Vertical grid lines */} @@ -219,6 +258,35 @@ export const TryFairDraggableGrid = ({
      )} + + {hoverTooltip ? ( +
      +
      +
      +

      + Buildings detected +

      +

      + {hoverTooltip.count.toLocaleString()} +

      +
      +
      +
      +
      + ) : null}
      ); }; diff --git a/frontend/src/features/try-fair/components/map/try-fair-layer-control.tsx b/frontend/src/features/try-fair/components/map/try-fair-layer-control.tsx index e2c6ef3c9..0842bc18f 100644 --- a/frontend/src/features/try-fair/components/map/try-fair-layer-control.tsx +++ b/frontend/src/features/try-fair/components/map/try-fair-layer-control.tsx @@ -92,7 +92,7 @@ export const TryFairLayerControl = ({ const predictionSwatchClassName = "bg-[#A147D8]"; const setMapLayerVisibility = (layerId: string, visible: boolean) => { - if (!map?.isStyleLoaded()) return; + if (!map) return; if (!map.getLayer(layerId)) return; map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none"); }; From 7684b4d8e2c5aa443c6b0d0a9660d01ac6c017b1 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Thu, 28 May 2026 17:36:17 +0100 Subject: [PATCH 55/62] chore: update navbar and map click count --- frontend/src/components/layouts/navbar/navbar.tsx | 10 ++-------- .../features/try-fair/components/try-fair-banner.tsx | 2 +- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/layouts/navbar/navbar.tsx b/frontend/src/components/layouts/navbar/navbar.tsx index f4300c8b3..fb0ccf42e 100644 --- a/frontend/src/components/layouts/navbar/navbar.tsx +++ b/frontend/src/components/layouts/navbar/navbar.tsx @@ -9,7 +9,6 @@ import { navLinks } from "@/constants/general"; import { NavLogo } from "@/components/layouts"; import { APPLICATION_ROUTES, SHARED_CONTENT } from "@/constants"; import { useAuth } from "@/app/providers/auth-provider"; -import { useTryFairStore } from "@/store/try-fair-store"; import { useLocation, useNavigate } from "react-router-dom"; import { UserProfile } from "@/components/layouts"; import { useState } from "react"; @@ -47,7 +46,6 @@ export const NavBar = () => { const [open, setOpen] = useState(false); const { isAuthenticated } = useAuth(); - const highlightStartMapping = useTryFairStore((s) => s.highlightStartMapping); const navigate = useNavigate(); @@ -112,9 +110,7 @@ export const NavBar = () => { /> ) : (
      - {highlightStartMapping && isTryFairPage && ( -
      - )} +
      diff --git a/frontend/src/features/try-fair/components/map/try-fair-layer-control.tsx b/frontend/src/features/try-fair/components/map/try-fair-layer-control.tsx index 0842bc18f..afa19eb72 100644 --- a/frontend/src/features/try-fair/components/map/try-fair-layer-control.tsx +++ b/frontend/src/features/try-fair/components/map/try-fair-layer-control.tsx @@ -220,7 +220,7 @@ export const TryFairLayerControl = ({ } > } onClick={toggleImagery} @@ -236,7 +236,7 @@ export const TryFairLayerControl = ({ } > } onClick={() => toggleBasemap("osm")} 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 f12cd2859..8881d3dba 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 @@ -11,6 +11,7 @@ import { TryFairPointsLegend } from "@/features/try-fair/components/map/points-l import { FitToBounds, ZoomControls } from "@/components/map/controls"; import { PREDICTION_LAYER_IDS } from "@/features/try-fair/utils/common"; import { TryFairLayerControl } from "@/features/try-fair/components/map/try-fair-layer-control"; +import useScreenSize from "@/hooks/use-screen-size"; type TryFairMapProps = { map: Map | null; @@ -46,6 +47,7 @@ export const TryFairMap = ({ isPredicting = false, onGridZoom, }: TryFairMapProps) => { + const { isSmallViewport } = useScreenSize(); const [choroplethBuckets, setChoroplethBuckets] = useState< ChoroplethBucket[] | null >(null); @@ -71,20 +73,11 @@ export const TryFairMap = ({ onGridZoom?.(); }, [map, onGridZoom]); - // Disable all map interactions while a prediction is running so the grid - // stays anchored over the area that was submitted. + // While predicting, disable only map dragging/panning. + // Keep zoom interactions enabled. useEffect(() => { if (!map) return; - const handlers = [ - map.scrollZoom, - map.boxZoom, - map.dragRotate, - map.dragPan, - map.keyboard, - map.doubleClickZoom, - map.touchZoomRotate, - map.touchPitch, - ]; + const handlers = [map.dragRotate, map.dragPan, map.touchPitch]; if (isPredicting) { handlers.forEach((handler) => handler?.disable()); } else { @@ -96,6 +89,13 @@ export const TryFairMap = ({ }; }, [map, isPredicting]); + const legend = + outputType === TryFairMapOutputType.CLUSTER ? ( + + ) : outputType === TryFairMapOutputType.POINTS ? ( + + ) : null; + return (
      )} - {/* ── Right-side control strip ──────────────────────────────────── */} + {/* ── Right-side */} {map && (
      {/* Zoom in / out */} @@ -154,13 +157,12 @@ export const TryFairMap = ({
      )} - {outputType === TryFairMapOutputType.CLUSTER && ( - - )} - - {outputType === TryFairMapOutputType.POINTS && ( - - )} + {legend && + (isSmallViewport ? ( +
      {legend}
      + ) : ( + legend + ))}
      ); }; 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 93eeabbd7..8baa05e6a 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 @@ type ModelPickerProps = { onSelect: (model: BaseModelStacItem) => void; models: BaseModelStacItem[]; loading?: boolean; + disabled?: boolean }; // const FeatureBadge = ({ label, type }: { label: string; type: string }) => ( @@ -34,6 +35,7 @@ export const ModelPicker: React.FC = ({ onSelect, models, loading = false, + disabled = false }) => { const [isOpen, setIsOpen] = useState(false); const [panelStyle, setPanelStyle] = useState({}); @@ -90,7 +92,8 @@ export const ModelPicker: React.FC = ({ ref={triggerRef} type="button" onClick={() => setIsOpen((v) => !v)} - className="flex h-[40px] justify-between gap-2 items-center w-full min-w-0" + disabled={disabled} + className="flex h-[40px] justify-between disabled:cursor-wait gap-2 items-center w-full min-w-0" >
      {loading ? ( diff --git a/frontend/src/features/try-fair/components/try-fair-banner.tsx b/frontend/src/features/try-fair/components/try-fair-banner.tsx deleted file mode 100644 index 3a4ecd27f..000000000 --- a/frontend/src/features/try-fair/components/try-fair-banner.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { CloseIcon } from "@/components/ui/icons"; - -type TryFairBannerProps = { - mapClickCount: number; - onDismiss: () => void; -}; - -export const TryFairBanner = ({ - mapClickCount, - onDismiss, -}: TryFairBannerProps) => { - const isSecondRun = mapClickCount === 4; - - return ( -
      -
      - {isSecondRun ? ( - <> -

      Take it further

      -

      - Export your results and access advanced mapping tools. -

      - - ) : ( - <> -

      - Want more results? -

      -

      - Try adjusting confidence or resolution — small changes can reveal - more features. -

      - - )} -
      - -
      - ); -}; 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 01ed99e5b..2ef9c6896 100644 --- a/frontend/src/features/try-fair/components/try-fair-sidebar.tsx +++ b/frontend/src/features/try-fair/components/try-fair-sidebar.tsx @@ -2,6 +2,7 @@ import { TryFairMapOutputType, TryFairResolution } from "@/enums/try-fair"; import { InfoIcon } from "@/components/ui/icons"; import { ModelPicker } from "./model-picker-modal"; import { TRY_FAIR_PAGE_CONTENT } from "@/constants/ui-contents/try-fair-contents"; +import { APP_TOUR_IDS } from "@/constants/site-tour"; import { Button } from "@/components/ui/button"; import { MapPlayIcon } from "@/components/ui/icons/map-play-icon"; import { ParametersIcon } from "@/components/ui/icons/parameters-icon"; @@ -74,6 +75,7 @@ export const TryFairSidebar = ({ selectedModel={selectedModel} onSelect={onSelectModel} models={models} + disabled={isPredicting} loading={modelsLoading} />
      @@ -87,7 +89,7 @@ export const TryFairSidebar = ({ +
      + + ); +}; diff --git a/frontend/src/features/try-fair/hooks/use-tile-grid.ts b/frontend/src/features/try-fair/hooks/use-tile-grid.ts index fd0fca54f..4ccc39f7c 100644 --- a/frontend/src/features/try-fair/hooks/use-tile-grid.ts +++ b/frontend/src/features/try-fair/hooks/use-tile-grid.ts @@ -4,8 +4,8 @@ import { BBOX } from "@/types"; import { TryFairResolution } from "@/enums/try-fair"; import { TileAnchor, - computeCenteredAnchor, computeGridBBox, + computeCenteredAnchor, getTileZoomForResolution, snapAnchorToTileBoundary, } from "@/features/try-fair/utils/tile-math"; @@ -68,6 +68,7 @@ export const useTileGrid = ({ }: UseTileGridOptions): UseTileGridReturn => { const [anchor, setAnchor] = useState(null); const tileZoom = getTileZoomForResolution(resolution); + const resolvedResolution = resolution ?? TryFairResolution.MID; // ── Track previous values to detect what changed ───────────────────────── @@ -78,6 +79,16 @@ export const useTileGrid = ({ const previousImageryCenterRef = useRef<[number, number] | undefined>( imageryCenter, ); + const anchorRef = useRef(null); + const anchorsByResolutionRef = useRef< + Partial> + >({}); + + useEffect(() => { + anchorRef.current = anchor; + if (!anchor) return; + anchorsByResolutionRef.current[resolvedResolution] = anchor; + }, [anchor, resolvedResolution]); // Consolidated recentering effect useEffect(() => { @@ -95,38 +106,62 @@ export const useTileGrid = ({ previousResolutionRef.current = resolution; previousImageryCenterRef.current = imageryCenter; - // Model changed — recenter from map center + // Model changed — reset per-resolution cache and recenter from map center. if (modelChanged && modelId) { const mapCenter = map.getCenter(); - setAnchor( - computeCenteredAnchor( - { lng: mapCenter.lng, lat: mapCenter.lat }, - tileZoom, - ), + const nextAnchor = computeCenteredAnchor( + { lng: mapCenter.lng, lat: mapCenter.lat }, + tileZoom, ); + anchorsByResolutionRef.current = { [resolvedResolution]: nextAnchor }; + setAnchor(nextAnchor); return; } - // the grid footprint changes, so recenter from the current map center. + // Resolution changed: restore cached anchor for that resolution if present. + // Otherwise, preserve the current grid center as closely as possible. if (resolutionChanged) { - const mapCenter = map.getCenter(); - setAnchor( - computeCenteredAnchor( - { lng: mapCenter.lng, lat: mapCenter.lat }, + const cachedAnchor = anchorsByResolutionRef.current[resolvedResolution]; + if (cachedAnchor) { + setAnchor(cachedAnchor); + return; + } + + const currentAnchor = anchorRef.current; + if (currentAnchor) { + const currentBBox = computeGridBBox( + snapAnchorToTileBoundary(currentAnchor), + ); + const nextAnchor = computeCenteredAnchor( + { + lng: (currentBBox[0] + currentBBox[2]) / 2, + lat: (currentBBox[1] + currentBBox[3]) / 2, + }, tileZoom, - ), + ); + anchorsByResolutionRef.current[resolvedResolution] = nextAnchor; + setAnchor(nextAnchor); + return; + } + + const mapCenter = map.getCenter(); + const nextAnchor = computeCenteredAnchor( + { lng: mapCenter.lng, lat: mapCenter.lat }, + tileZoom, ); + anchorsByResolutionRef.current[resolvedResolution] = nextAnchor; + setAnchor(nextAnchor); return; } - // Imagery center resolved/changed — recenter on it. + // Imagery center resolved/changed — reset cache and recenter on imagery. if (imageryCenterChanged && imageryCenter) { - setAnchor( - computeCenteredAnchor( - { lng: imageryCenter[0], lat: imageryCenter[1] }, - tileZoom, - ), + const nextAnchor = computeCenteredAnchor( + { lng: imageryCenter[0], lat: imageryCenter[1] }, + tileZoom, ); + anchorsByResolutionRef.current = { [resolvedResolution]: nextAnchor }; + setAnchor(nextAnchor); return; } @@ -135,15 +170,16 @@ export const useTileGrid = ({ const target = imageryCenter ? { lng: imageryCenter[0], lat: imageryCenter[1] } : map.getCenter(); - setAnchor(computeCenteredAnchor(target, tileZoom)); + const nextAnchor = computeCenteredAnchor(target, tileZoom); + anchorsByResolutionRef.current[resolvedResolution] = nextAnchor; + setAnchor(nextAnchor); } // `anchor` is intentionally excluded from deps — we only want to // initialise when there's no anchor. Subsequent anchor updates are // driven by dragging, not by this effect. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [map, modelId, resolution, imageryCenter, tileZoom]); + }, [map, modelId, resolution, imageryCenter, tileZoom, resolvedResolution]); - // ── Notify parent when the *snapped* bbox changes ──────────────────────── // // The old code called onBBoxChange on every fractional anchor update during // drags. We now compare the snapped bbox and only fire when it actually diff --git a/frontend/src/features/try-fair/utils/common.tsx b/frontend/src/features/try-fair/utils/common.tsx index 19d9e0596..7d609b9f3 100644 --- a/frontend/src/features/try-fair/utils/common.tsx +++ b/frontend/src/features/try-fair/utils/common.tsx @@ -4,6 +4,7 @@ import { PointsIcon } from "@/components/ui/icons/points-icons"; import { ClusterIcon } from "@/components/ui/icons/cluster-icon"; import { PolygonIcon } from "@/components/ui/icons/polygon-icon"; import React from "react"; +export const TRY_FAIR_INITIAL_MAP_ZOOM = 18; export const RESOLUTIONS: { value: TryFairResolution; diff --git a/frontend/src/features/try-fair/utils/helpers.ts b/frontend/src/features/try-fair/utils/helpers.ts index c49f4eef0..4cb9907bc 100644 --- a/frontend/src/features/try-fair/utils/helpers.ts +++ b/frontend/src/features/try-fair/utils/helpers.ts @@ -61,7 +61,6 @@ export const toPointCollection = ( export const CHOROPLETH_GRID_COLS = 5; export const CHOROPLETH_GRID_ROWS = 5; -/** Lavender → deep purple ramp (5 buckets, matches design) */ export const CHOROPLETH_COLORS = [ "#E5CEF2", "#C58EE4", @@ -77,7 +76,6 @@ export type ChoroplethBucket = { label: string; }; -// ── Choropleth grid spec (mirrors draggable-grid.tsx — must stay in sync) ─── /** * Floor-free tile coordinate conversion (matches lngLatToTileCoords in @@ -117,7 +115,6 @@ export const buildChoropleth = ( return buildEqualDegreeChoropleth(predictions, bbox); }; -// ── Tile-aligned (primary path) ─────────────────────────────────────────────── const buildTileAlignedChoropleth = ( predictions: GeoJSON.FeatureCollection, @@ -195,7 +192,6 @@ const buildTileAlignedChoropleth = ( return { type: "FeatureCollection", features }; }; -// ── Equal-degree fallback (kept for non-tile-zoom contexts) ─────────────────── const buildEqualDegreeChoropleth = ( predictions: GeoJSON.FeatureCollection, diff --git a/frontend/src/store/try-fair-store.ts b/frontend/src/store/try-fair-store.ts deleted file mode 100644 index 2997f2394..000000000 --- a/frontend/src/store/try-fair-store.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { create } from "zustand"; - -type TryFairState = { - highlightStartMapping: boolean; - setHighlightStartMapping: (value: boolean) => void; -}; - -export const useTryFairStore = create((set) => ({ - highlightStartMapping: false, - setHighlightStartMapping: (value) => set({ highlightStartMapping: value }), -})); From 640bdb33a256d68d1861880b3604ea7b0aba9849 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Fri, 29 May 2026 23:24:56 +0100 Subject: [PATCH 60/62] feat: added site tour and download --- frontend/src/app/routes/try-fair.tsx | 10 ++++++---- frontend/src/components/layouts/navbar/navbar.tsx | 8 ++++---- frontend/src/components/ui/drawer/mobile-drawer.tsx | 4 +--- frontend/src/constants/site-tour.ts | 5 +---- .../try-fair/components/model-picker-modal.tsx | 4 ++-- .../try-fair/components/try-fair-welcome-dialog.tsx | 3 +-- frontend/src/features/try-fair/utils/common.tsx | 2 +- frontend/src/features/try-fair/utils/helpers.ts | 3 --- 8 files changed, 16 insertions(+), 23 deletions(-) diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index 98bacc5ec..1c4df45c6 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -75,7 +75,6 @@ export const TryFairPage = () => { [selectedModel], ); - const paramValues = useMemo(() => { const values: Record = {}; inferenceParams.forEach(({ key, spec }) => { @@ -96,8 +95,12 @@ export const TryFairPage = () => { const [allowInitialPrediction, setAllowInitialPrediction] = useState( () => hasSeenWelcomeDialog, ); - const [tourStepOneSeen, setTourStepOneSeen] = useState(() => hasSeenTourStepOne); - const [tourStepTwoSeen, setTourStepTwoSeen] = useState(() => hasSeenTourStepTwo); + const [tourStepOneSeen, setTourStepOneSeen] = useState( + () => hasSeenTourStepOne, + ); + const [tourStepTwoSeen, setTourStepTwoSeen] = useState( + () => hasSeenTourStepTwo, + ); const autoTriggeredRef = useRef(false); const tileServiceUrl = demoConfig?.tileServiceUrl ?? ""; @@ -125,7 +128,6 @@ export const TryFairPage = () => { return demoConfig?.center; }, [tileJSONMetadata, demoConfig]); - useEffect(() => { if (!map || !demoConfig || !imageryCenter) return; diff --git a/frontend/src/components/layouts/navbar/navbar.tsx b/frontend/src/components/layouts/navbar/navbar.tsx index b32cce4f7..686972954 100644 --- a/frontend/src/components/layouts/navbar/navbar.tsx +++ b/frontend/src/components/layouts/navbar/navbar.tsx @@ -110,10 +110,10 @@ export const NavBar = () => { setOpen={setOpen} /> ) : ( -
      -
      diff --git a/frontend/src/constants/site-tour.ts b/frontend/src/constants/site-tour.ts index 1fb7bd6e0..d11842ead 100644 --- a/frontend/src/constants/site-tour.ts +++ b/frontend/src/constants/site-tour.ts @@ -40,9 +40,7 @@ export const APP_TOUR_STEPS = [ }, ]; -export const getTryFairTourSteps = ( - isSmallViewport: boolean, -): StepType[] => [ +export const getTryFairTourSteps = (isSmallViewport: boolean): StepType[] => [ { selector: `#${APP_TOUR_IDS.TRY_FAIR_PARAMETERS}`, content: @@ -53,6 +51,5 @@ export const getTryFairTourSteps = ( selector: `#${APP_TOUR_IDS.TRY_FAIR_START_MAPPING_BUTTON}`, content: "Ready for full mapping? Click Start Mapping to continue with advanced tools.", - }, ]; 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 8baa05e6a..a82cb8208 100644 --- a/frontend/src/features/try-fair/components/model-picker-modal.tsx +++ b/frontend/src/features/try-fair/components/model-picker-modal.tsx @@ -8,7 +8,7 @@ type ModelPickerProps = { onSelect: (model: BaseModelStacItem) => void; models: BaseModelStacItem[]; loading?: boolean; - disabled?: boolean + disabled?: boolean; }; // const FeatureBadge = ({ label, type }: { label: string; type: string }) => ( @@ -35,7 +35,7 @@ export const ModelPicker: React.FC = ({ onSelect, models, loading = false, - disabled = false + disabled = false, }) => { const [isOpen, setIsOpen] = useState(false); const [panelStyle, setPanelStyle] = useState({}); diff --git a/frontend/src/features/try-fair/components/try-fair-welcome-dialog.tsx b/frontend/src/features/try-fair/components/try-fair-welcome-dialog.tsx index d0e4bcff8..79e09d71e 100644 --- a/frontend/src/features/try-fair/components/try-fair-welcome-dialog.tsx +++ b/frontend/src/features/try-fair/components/try-fair-welcome-dialog.tsx @@ -11,9 +11,8 @@ export const TryFairWelcomeDialog = ({ isOpened, onContinue, }: TryFairWelcomeDialogProps) => { - return ( - null} preventClose noHeader> + null} preventClose noHeader>

      Welcome to Try fAIr diff --git a/frontend/src/features/try-fair/utils/common.tsx b/frontend/src/features/try-fair/utils/common.tsx index 7d609b9f3..e3133b2ca 100644 --- a/frontend/src/features/try-fair/utils/common.tsx +++ b/frontend/src/features/try-fair/utils/common.tsx @@ -4,7 +4,7 @@ import { PointsIcon } from "@/components/ui/icons/points-icons"; import { ClusterIcon } from "@/components/ui/icons/cluster-icon"; import { PolygonIcon } from "@/components/ui/icons/polygon-icon"; import React from "react"; -export const TRY_FAIR_INITIAL_MAP_ZOOM = 18; +export const TRY_FAIR_INITIAL_MAP_ZOOM = 18; export const RESOLUTIONS: { value: TryFairResolution; diff --git a/frontend/src/features/try-fair/utils/helpers.ts b/frontend/src/features/try-fair/utils/helpers.ts index 4cb9907bc..20ff0d470 100644 --- a/frontend/src/features/try-fair/utils/helpers.ts +++ b/frontend/src/features/try-fair/utils/helpers.ts @@ -76,7 +76,6 @@ export type ChoroplethBucket = { label: string; }; - /** * Floor-free tile coordinate conversion (matches lngLatToTileCoords in * draggable-grid.tsx). Unlike deg2num, this does NOT apply Math.floor so we @@ -115,7 +114,6 @@ export const buildChoropleth = ( return buildEqualDegreeChoropleth(predictions, bbox); }; - const buildTileAlignedChoropleth = ( predictions: GeoJSON.FeatureCollection, bbox: BBOX, @@ -192,7 +190,6 @@ const buildTileAlignedChoropleth = ( return { type: "FeatureCollection", features }; }; - const buildEqualDegreeChoropleth = ( predictions: GeoJSON.FeatureCollection, bbox: BBOX, From 5ed49f39a83132eafd52957d46544812fbcd4b35 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Sat, 30 May 2026 10:04:14 +0100 Subject: [PATCH 61/62] chore: type usestates --- frontend/src/app/routes/try-fair.tsx | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index 1c4df45c6..68e89cf8c 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -85,23 +85,23 @@ export const TryFairPage = () => { const [latestBBox, setLatestBBox] = useState(null); const [latestGridZoom, setLatestGridZoom] = useState(null); - const [isDirty, setIsDirty] = useState(true); + const [isDirty, setIsDirty] = useState(true); - const [gridZoomed, setGridZoomed] = useState(false); - const [mapClickCount, setMapClickCount] = useState(0); - const [showWelcomeModal, setShowWelcomeModal] = useState( + const [gridZoomed, setGridZoomed] = useState(false); + const [mapClickCount, setMapClickCount] = useState(0); + const [showWelcomeModal, setShowWelcomeModal] = useState( () => !hasSeenWelcomeDialog, ); - const [allowInitialPrediction, setAllowInitialPrediction] = useState( - () => hasSeenWelcomeDialog, - ); - const [tourStepOneSeen, setTourStepOneSeen] = useState( - () => hasSeenTourStepOne, - ); - const [tourStepTwoSeen, setTourStepTwoSeen] = useState( - () => hasSeenTourStepTwo, - ); - const autoTriggeredRef = useRef(false); + const [allowInitialPrediction, setAllowInitialPrediction] = useState< + boolean + >(() => hasSeenWelcomeDialog); + const [tourStepOneSeen, setTourStepOneSeen] = useState< + boolean + >(() => hasSeenTourStepOne); + const [tourStepTwoSeen, setTourStepTwoSeen] = useState< + boolean + >(() => hasSeenTourStepTwo); + const autoTriggeredRef = useRef(false); const tileServiceUrl = demoConfig?.tileServiceUrl ?? ""; From fa410b1dbad02981c82ca1f0959a8781d4adacfa Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Sat, 30 May 2026 10:05:31 +0100 Subject: [PATCH 62/62] chore: type usestates --- frontend/src/app/routes/try-fair.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/routes/try-fair.tsx b/frontend/src/app/routes/try-fair.tsx index 68e89cf8c..f8d40282f 100644 --- a/frontend/src/app/routes/try-fair.tsx +++ b/frontend/src/app/routes/try-fair.tsx @@ -92,15 +92,15 @@ export const TryFairPage = () => { const [showWelcomeModal, setShowWelcomeModal] = useState( () => !hasSeenWelcomeDialog, ); - const [allowInitialPrediction, setAllowInitialPrediction] = useState< - boolean - >(() => hasSeenWelcomeDialog); - const [tourStepOneSeen, setTourStepOneSeen] = useState< - boolean - >(() => hasSeenTourStepOne); - const [tourStepTwoSeen, setTourStepTwoSeen] = useState< - boolean - >(() => hasSeenTourStepTwo); + const [allowInitialPrediction, setAllowInitialPrediction] = useState( + () => hasSeenWelcomeDialog, + ); + const [tourStepOneSeen, setTourStepOneSeen] = useState( + () => hasSeenTourStepOne, + ); + const [tourStepTwoSeen, setTourStepTwoSeen] = useState( + () => hasSeenTourStepTwo, + ); const autoTriggeredRef = useRef(false); const tileServiceUrl = demoConfig?.tileServiceUrl ?? "";