From 320f11550e931e577dde588189897df8b2eeba55 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 16 Jun 2026 10:44:31 +0100 Subject: [PATCH 01/21] feat: restore base model --- frontend/src/app/router.tsx | 28 ++ .../routes/base-models/base-model-detail.tsx | 381 ++++++++++++++++++ .../routes/base-models/base-models-list.tsx | 210 ++++++++++ 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 | 38 ++ .../src/components/shared/markdown-render.tsx | 34 ++ frontend/src/constants/routes.ts | 6 + .../constants/ui-contents/shared-content.ts | 110 +++++ .../base-models/api/get-base-models.ts | 22 + .../components/base-model-card.tsx | 45 +++ .../components/base-model-detail-skeleton.tsx | 88 ++++ .../components/base-model-keywords.tsx | 32 ++ .../components/base-model-list-skeleton.tsx | 35 ++ .../components/base-models-filters.tsx | 166 ++++++++ .../components/contribute-model-dialog.tsx | 180 +++++++++ .../features/base-models/components/index.ts | 8 + .../components/mobile-base-model-filters.tsx | 115 ++++++ .../components/model-extent-map.tsx | 77 ++++ .../base-models/hooks/use-base-models.ts | 43 ++ .../src/features/base-models/layouts/grid.tsx | 20 + .../src/features/base-models/layouts/index.ts | 2 + .../features/base-models/layouts/table.tsx | 78 ++++ .../src/features/base-models/utils/common.ts | 4 + .../src/features/base-models/utils/stac.ts | 136 +++++++ frontend/src/types/api.ts | 1 + frontend/src/types/ui-contents.ts | 38 ++ 29 files changed, 2048 insertions(+) create mode 100644 frontend/src/app/routes/base-models/base-model-detail.tsx create mode 100644 frontend/src/app/routes/base-models/base-models-list.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/components/shared/markdown-render.tsx create mode 100644 frontend/src/features/base-models/api/get-base-models.ts create mode 100644 frontend/src/features/base-models/components/base-model-card.tsx create mode 100644 frontend/src/features/base-models/components/base-model-detail-skeleton.tsx create mode 100644 frontend/src/features/base-models/components/base-model-keywords.tsx create mode 100644 frontend/src/features/base-models/components/base-model-list-skeleton.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 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/components/model-extent-map.tsx create mode 100644 frontend/src/features/base-models/hooks/use-base-models.ts 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 create mode 100644 frontend/src/features/base-models/utils/common.ts create mode 100644 frontend/src/features/base-models/utils/stac.ts diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 042ac7e7d..4ece434c1 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -340,7 +340,35 @@ const router = createBrowserRouter([ /** * Start mapping route ends. */ + /** + * Base Models routes. + */ + { + path: APPLICATION_ROUTES.BASE_MODELS_HOME, + lazy: async () => { + const { BaseModelsPage } = await import( + "@/app/routes/base-models/base-models-list" + ); + return { + Component: () => , + }; + }, + }, + { + path: APPLICATION_ROUTES.BASE_MODEL_DETAILS, + lazy: async () => { + const { BaseModelDetailPage } = await import( + "@/app/routes/base-models/base-model-detail" + ); + return { + Component: () => , + }; + }, + }, + /** + * Base Models routes ends. + */ /** * User account routes start. */ diff --git a/frontend/src/app/routes/base-models/base-model-detail.tsx b/frontend/src/app/routes/base-models/base-model-detail.tsx new file mode 100644 index 000000000..fc4c01859 --- /dev/null +++ b/frontend/src/app/routes/base-models/base-model-detail.tsx @@ -0,0 +1,381 @@ +import { Head } from "@/components/seo"; +import { BackButton, ButtonWithIcon } from "@/components/ui/button"; +import { ChevronDownIcon, InfoIcon, MapIcon } from "@/components/ui/icons"; +import { DownloadIconNew } from "@/components/ui/icons/download-icon"; +import { ToolTip } from "@/components/ui/tooltip"; +import { Link } from "@/components/ui/link"; +import { APPLICATION_ROUTES } from "@/constants"; +import { ButtonVariant } from "@/enums"; + +// import AccuracyDisplay from "@/features/models/components/accuracy-display"; +import { useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useBaseModel } from "@/features/base-models/hooks/use-base-models"; +import MarkdownViewer from "@/components/shared/markdown-render"; +import { BaseModelDetailSkeleton, BaseModelKeywords, ModelExtentMap } from "@/features/base-models/components"; +import { formatDate } from "@/utils"; + +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}

+
+); + + +export const BaseModelDetailPage = () => { + const { id } = useParams(); + const navigate = useNavigate(); + + const { data: model, isLoading, isError } = useBaseModel(id); + + if (isLoading) { + return ; + } + + if (isError || !model) { + return
Failed to load model
; + } + + const architectureRows: TInfoRowConfig[] = model + ? [ + { label: "Base Model", value: model.architecture.baseModel }, + { label: "Architecture", value: model.architecture.architecture }, + { label: "Framework", value: model.architecture.framework }, + { label: "Framework Version", value: model.architecture.frameworkVersion }, + { label: "Pretrained", value: model.architecture.pretrained }, + { label: "Pretrained Source", value: model.architecture.pretrainedSource }, + { label: "Accelerator", value: model.architecture.accelerator }, + { label: "Accelerator Count", value: model.architecture.acceleratorCount }, + { label: "CPU Request", value: model.architecture.cpuRequest }, + { label: "Memory Limit", value: model.architecture.memoryLimit }, + { 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: "Description", + value: model.architecture.outputDescription, + tooltip: "Description of the model output", + }, + ] + : []; + + const mlmRows: TInfoRowConfig[] = model + ? [ + { label: "Tasks", value: model.mlmTasks.join(", ") }, + // Input + ...(model.mlmInput[0] + ? [ + { label: "Input Name", value: model.mlmInput[0].name }, + { + label: "Input Bands", + value: model.mlmInput[0].bands.map((b: { name: string }) => b.name).join(", "), + }, + { label: "Input Shape", value: model.mlmInput[0].input.shape.join(" × ") }, + { label: "Input Data Type", value: model.mlmInput[0].input.data_type }, + { label: "Input Dim Order", value: model.mlmInput[0].input.dim_order.join(", ") }, + ...(model.mlmInput[0].pre_processing_function + ? [{ label: "Pre-processing", value: model.mlmInput[0].pre_processing_function.expression }] + : []), + ] + : []), + // Output + ...(model.mlmOutput[0] + ? [ + { label: "Output Name", value: model.mlmOutput[0].name }, + { + label: "Output Bands", + value: + model.mlmOutput[0].bands.length > 0 + ? model.mlmOutput[0].bands.map((b: { name: string }) => b.name).join(", ") + : "n/a", + }, + { label: "Output Tasks", value: model.mlmOutput[0].tasks.join(", ") }, + { label: "Output Shape", value: model.mlmOutput[0].result.shape.join(" × ") }, + { label: "Output Data Type", value: model.mlmOutput[0].result.data_type }, + { label: "Output Dim Order", value: model.mlmOutput[0].result.dim_order.join(", ") }, + ...(model.mlmOutput[0]["classification:classes"]?.length + ? [ + { + label: "Classes", + value: model.mlmOutput[0]["classification:classes"] + .map((c: { name: string; value: number }) => `${c.name} (${c.value})`) + .join(", "), + }, + ] + : []), + ...(model.mlmOutput[0].post_processing_function + ? [{ label: "Post-processing", value: model.mlmOutput[0].post_processing_function.expression }] + : []), + ] + : []), + ].filter((row) => row.value != null && row.value !== "") + : []; + + 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", + }, + ] + : []; + + + + const generalInfoRows: TInfoRowConfig[] = model + ? [ + { label: "Created", value: formatDate(model.generatedOn) }, + { label: "License", value: model.modelWeightsLicense }, + { label: "Updated", value: formatDate(model.lastModified) }, + { label: "Data Version", value: model.version }, + { label: "Time of Data", value: formatDate(model.dataDatetime) }, + ].filter((row) => row.value != null && row.value !== "") + : []; + + if (!model) { + return null; + } + + return ( + <> + + + +
+ {/* Title + Start Mapping */} +
+
+

+ {model.fullTitle} +

+

Model ID: {model.dataId}

+
+
+ + navigate(`${APPLICATION_ROUTES.START_MAPPING_BASE}${model.id}`) + } + prefixIcon={MapIcon} + variant={ButtonVariant.PRIMARY} + label="Start Mapping" + /> +
+
+ + {/* Metadata + Map Extent — metadata on left, map on right */} +
+ {/* Left: metadata */} +
+ + + + + + +
+ Task: + +
+ +
+ + {/* Right: map extent — justified to the end */} + {model.bbox ? ( + + ) : null} +
+ + {/* Download Metadata Link */} + {model.readmeUrl && ( +
+ + Download Metadata + + +
+ )} + + {/* Main Content: Two Column Layout */} +
+ {/* Left Column - Overview */} + + + {/* Right Column - Architecture Info */} +
+ +
+ {architectureRows.map((row) => ( + + ))} +
+
+ + +
+ {generalInfoRows.map((row) => ( + + ))} +
+
+ + +
+ {mlmRows.map((row) => ( + + ))} +
+
+ + +
+ {dataInfoRows.map((row) => ( + + ))} +
+
+
+
+
+ + ); +}; diff --git a/frontend/src/app/routes/base-models/base-models-list.tsx b/frontend/src/app/routes/base-models/base-models-list.tsx new file mode 100644 index 000000000..ae9d71b78 --- /dev/null +++ b/frontend/src/app/routes/base-models/base-models-list.tsx @@ -0,0 +1,210 @@ +import { Head } from "@/components/seo"; +import { ButtonWithIcon } from "@/components/ui/button"; +import { AddIcon, NoTrainingAreaIcon } from "@/components/ui/icons"; +import { SHARED_CONTENT } from "@/constants"; +import { ButtonVariant, LayoutView } from "@/enums"; +import { useDialog } from "@/hooks/use-dialog"; +import { useMemo } from "react"; +import { parseAsString, useQueryStates } from "nuqs"; +import ContributeModelDialog from "@/features/base-models/components/contribute-model-dialog"; +import { + BaseModelsFilters, + MobileBaseModelFiltersDialog, + BaseModelListSkeleton, +} from "@/features/base-models/components"; +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(); + + const { + isOpened: isMobileFiltersOpen, + openDialog: openMobileFilters, + closeDialog: closeMobileFilters, + } = useDialog(); + + 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; + + 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 result = [...models]; + + if (search) { + const searchLower = search.toLowerCase(); + + result = result.filter( + (model) => + model.name.toLowerCase().includes(searchLower) || + model.description.toLowerCase().includes(searchLower) || + model.author.toLowerCase().includes(searchLower), + ); + } + + if (category && category !== "all") { + result = result.filter((model) => model.task === category); + } + + result.sort((a, b) => { + const aDate = new Date(a.updatedAt).getTime(); + const bDate = new Date(b.updatedAt).getTime(); + + if (dateSort === "oldest") return aDate - bDate; + return bDate - aDate; + }); + + 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]); + + const dateMenuItems = DATE_SORT_OPTIONS.map((opt) => ({ + value: opt.label, + apiValue: opt.value, + })); + + const selectedCategoryLabel = + 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, + }); + }; + const renderContent = () => { + if (isLoading) { + return ; + } + + if (isError) { + return
Failed to load models
; + } + + if (filteredModels.length === 0) { + return ( +
+ +

No base models found

+ +
+ ); + } + + if (isListView) { + return ( +
+ +
+ ); + } + + return ; + }; + + return ( + <> + + + + setQueryStates({ category: value })} + setDateSort={(value) => setQueryStates({ date: value })} + /> + +
+
+
+

+ {SHARED_CONTENT.baseModelsPage.pageHeadingTitle} +

+ +
+ +
+
+ +

+ {SHARED_CONTENT.baseModelsPage.pageHeadingDescription} +

+
+ + setQueryStates({ q: value })} + categoryMenuItems={categoryMenuItems} + dateMenuItems={dateMenuItems} + selectedCategoryLabel={selectedCategoryLabel} + selectedDateLabel={selectedDateLabel} + setCategory={(value) => setQueryStates({ category: value })} + setDateSort={(value) => setQueryStates({ date: value })} + filteredModelsCount={filteredModels.length} + layout={layout} + onToggleLayout={toggleLayout} + onOpenMobileFilters={openMobileFilters} + /> + + {renderContent()} +
+ + ); +}; 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 75655b22a..155cb247e 100644 --- a/frontend/src/assets/images/index.ts +++ b/frontend/src/assets/images/index.ts @@ -16,3 +16,5 @@ export { default as IntermediateCourseImage } from "@/assets/images/intermediate export { default as AdvancedCourseImage } from "@/assets/images/advanced_course.png"; export { default as UpdateCoverImage } from "@/assets/images/cover.png"; export { default as fAIrSwipeIllustration } from "@/assets/images/fairswipe_illustration.png"; +export { default as BaseModelCTAImage } from "@/assets/images/base_model_cta_image.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..d6138871f --- /dev/null +++ b/frontend/src/components/landing/base-model-cta/base-model-cta.tsx @@ -0,0 +1,38 @@ +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/shared/markdown-render.tsx b/frontend/src/components/shared/markdown-render.tsx new file mode 100644 index 000000000..3a3c56bcb --- /dev/null +++ b/frontend/src/components/shared/markdown-render.tsx @@ -0,0 +1,34 @@ +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +type MarkdownViewerProps = { + content?: string; + className?: string; +}; + +const MarkdownViewer: React.FC = ({ + content, + className = "", +}) => { + if (!content?.trim()) { + return ( +
+ {/*

+ No documentation available for this model yet. +

+

+ Documentation will appear here once a README is published for this + model in the fAIr-models repository. +

*/} +
+ ); + } + + return ( +
+ {content} +
+ ); +}; + +export default MarkdownViewer; diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 222e7b6d2..fbbe9725d 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -67,6 +67,12 @@ export const APPLICATION_ROUTES = { // Published AI Predictions AI_PREDICTIONS: "/ai-predictions", + + + // base-model start + BASE_MODELS_HOME: "/base-models", + BASE_MODEL_DETAILS: "/base-models/:id", + // base-model end }; export const HOT_PRIVACY_POLICY_URL: string = "https://www.hotosm.org/privacy"; diff --git a/frontend/src/constants/ui-contents/shared-content.ts b/frontend/src/constants/ui-contents/shared-content.ts index a791a4bd7..97b946593 100644 --- a/frontend/src/constants/ui-contents/shared-content.ts +++ b/frontend/src/constants/ui-contents/shared-content.ts @@ -138,7 +138,16 @@ 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: + "Contribute a base model to fAIr and help teams turn imagery into actionable map data, faster and more reliably.", + ctaButton: "Contribute", + ctaLink: "/base-models", + }, }, + pageNotFound: { messages: { constant: "Oh sorry,", @@ -151,6 +160,107 @@ export const SHARED_CONTENT: TSharedContent = { trainingDatasetNotFound: "Explore training datasets", 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", + 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", 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 new file mode 100644 index 000000000..8866ce250 --- /dev/null +++ b/frontend/src/features/base-models/components/base-model-card.tsx @@ -0,0 +1,45 @@ +import { Link } from "@/components/ui/link"; +import { APPLICATION_ROUTES } from "@/constants"; +import { TBaseModel } from "@/types"; +import { BaseModelKeywords } from "@/features/base-models/components/base-model-keywords"; + +type BaseModelCardProps = { + model: TBaseModel; +}; + +const BaseModelCard: React.FC = ({ model }) => { + return ( + + {/* Model Name */} +

{model.name}

+ + {/* Description */} +

+ {model.description} +

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

+ {model.author} +

+

+ Last Modified: {model.lastModified} +

+
+ {/* Keywords */} + + + ); +}; + +export default BaseModelCard; + diff --git a/frontend/src/features/base-models/components/base-model-detail-skeleton.tsx b/frontend/src/features/base-models/components/base-model-detail-skeleton.tsx new file mode 100644 index 000000000..e6df3adfb --- /dev/null +++ b/frontend/src/features/base-models/components/base-model-detail-skeleton.tsx @@ -0,0 +1,88 @@ +const SkeletonBlock = ({ + className = "", +}: { + className?: string; +}) => ( +
+); + +const SidebarSectionSkeleton = () => ( +
+ {/* Section header */} +
+ + +
+ {/* Info rows */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+
+); + +export const BaseModelDetailSkeleton = () => { + return ( + <> + {/* Back button */} + + +
+ {/* Title + Start Mapping button row */} +
+
+ + +
+ +
+ + {/* Metadata 3-col grid */} +
+ {Array.from({ length: 3 }).map((_, col) => ( +
+ {Array.from({ length: 3 }).map((_, row) => ( +
+ + +
+ ))} +
+ ))} +
+ + {/* Download metadata link */} + + + {/* Main two-column layout */} +
+ {/* Left: markdown content */} +
+ + + + + + + + + + + +
+ + {/* Right: sidebar */} +
+ + + +
+
+
+ + ); +}; diff --git a/frontend/src/features/base-models/components/base-model-keywords.tsx b/frontend/src/features/base-models/components/base-model-keywords.tsx new file mode 100644 index 000000000..3cdee235c --- /dev/null +++ b/frontend/src/features/base-models/components/base-model-keywords.tsx @@ -0,0 +1,32 @@ +type BaseModelKeywordsProps = { + keywords: string[]; + visibleLimit?: number; +}; + +export const BaseModelKeywords = ({ + keywords, + visibleLimit = 3, +}: BaseModelKeywordsProps) => { + const sanitized = keywords.filter( + (k): k is string => !!k && k.trim().toLowerCase() !== "null", + ); + + if (sanitized.length === 0) return null; + + const visible = sanitized.slice(0, visibleLimit); + + + return ( +
+ {visible.map((keyword) => ( + + {keyword} + + ))} + +
+ ); +}; diff --git a/frontend/src/features/base-models/components/base-model-list-skeleton.tsx b/frontend/src/features/base-models/components/base-model-list-skeleton.tsx new file mode 100644 index 000000000..360e957b4 --- /dev/null +++ b/frontend/src/features/base-models/components/base-model-list-skeleton.tsx @@ -0,0 +1,35 @@ +const BaseModelCardSkeleton = () => ( +
+ {/* Model Name */} +
+ + {/* Description */} +
+
+
+
+
+ + {/* Accuracy */} +
+
+
+
+ + {/* Author & Date */} +
+
+
+
+
+); + +export const BaseModelListSkeleton = () => { + return ( +
+ {Array.from({ length: 9 }).map((_, index) => ( + + ))} +
+ ); +}; 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..b3abe8a17 --- /dev/null +++ b/frontend/src/features/base-models/components/base-models-filters.tsx @@ -0,0 +1,166 @@ +import { + SearchIcon, + CategoryIcon, + ListIcon, + FilterIcon, +} from "@/components/ui/icons"; +import { DropDown } from "@/components/ui/dropdown"; +import { ToolTip } from "@/components/ui/tooltip"; +import { LayoutView } from "@/enums"; + +type TMenuItem = { + value: string; + apiValue: string; +}; + +type BaseModelsFiltersProps = { + search: string; + setSearch: (value: string | null) => void; + categoryMenuItems: TMenuItem[]; + dateMenuItems: TMenuItem[]; + selectedCategoryLabel: string; + selectedDateLabel: string; + setCategory: (value: string | null) => void; + setDateSort: (value: string | null) => void; + filteredModelsCount: number; + layout: string; + onToggleLayout: () => void; + onOpenMobileFilters: () => void; +}; + +const BaseModelsFilters: React.FC = ({ + search, + setSearch, + categoryMenuItems, + dateMenuItems, + selectedCategoryLabel, + selectedDateLabel, + setCategory, + setDateSort, + filteredModelsCount, + layout, + onToggleLayout, + onOpenMobileFilters, +}) => { + const isListView = layout === LayoutView.LIST; + + return ( +
+
+
+
+ {/* Search */} +
+ + setSearch(e.target.value || null)} + placeholder="Search" + className="w-full p-2 outline-none border-none text-body-2base" + /> +
+ + {/* Category Filter — Desktop */} +
+ { + const selected = categoryMenuItems.find( + (c) => c.value === value, + ); + + if (!selected) return; + + setCategory( + selected.apiValue === "all" ? null : selected.apiValue, + ); + }} + defaultSelectedItem={selectedCategoryLabel} + triggerComponent={ +

+ {selectedCategoryLabel} +

+ } + /> +
+ + {/* Date Filter — Desktop */} +
+ { + const selected = dateMenuItems.find((d) => d.value === value); + + if (!selected) return; + + setDateSort( + selected.apiValue === "newest" ? null : selected.apiValue, + ); + }} + defaultSelectedItem={selectedDateLabel} + triggerComponent={ +

+ {selectedDateLabel} +

+ } + /> +
+
+ + {/* Right side controls */} +
+ {/* Mobile filter button */} +
+ +
+ + {/* Desktop layout toggle */} +
+ + + +
+
+
+
+ + {/* Model count + mobile controls */} +
+

+ {filteredModelsCount} Models +

+ + + + +
+
+ ); +}; + +export default BaseModelsFilters; 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..a71dbbdc2 --- /dev/null +++ b/frontend/src/features/base-models/components/contribute-model-dialog.tsx @@ -0,0 +1,180 @@ +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; + defaultOpen?: boolean; +}; + +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, + children, + defaultOpen = false, +}) => { + const [isExpanded, setIsExpanded] = useState(defaultOpen); + + 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; + + return ( + +
+

+ {contributeModelDialogContent.intro} +

+ + {contributeModelDialogContent.steps.map((step, index) => ( + +
+ {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/features/base-models/components/index.ts b/frontend/src/features/base-models/components/index.ts new file mode 100644 index 000000000..a864d263d --- /dev/null +++ b/frontend/src/features/base-models/components/index.ts @@ -0,0 +1,8 @@ +export { default as BaseModelCard } from "./base-model-card"; +export { default as ContributeModelDialog } from "./contribute-model-dialog"; +export { default as BaseModelsFilters } from "./base-models-filters"; +export { default as MobileBaseModelFiltersDialog } from "./mobile-base-model-filters"; +export { BaseModelListSkeleton } from "./base-model-list-skeleton"; +export { BaseModelDetailSkeleton } from "./base-model-detail-skeleton"; +export { BaseModelKeywords } from "./base-model-keywords"; +export { ModelExtentMap } from "./model-extent-map"; diff --git a/frontend/src/features/base-models/components/mobile-base-model-filters.tsx b/frontend/src/features/base-models/components/mobile-base-model-filters.tsx new file mode 100644 index 000000000..c7e4381da --- /dev/null +++ b/frontend/src/features/base-models/components/mobile-base-model-filters.tsx @@ -0,0 +1,115 @@ +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { DropDown } from "@/components/ui/dropdown"; +import { ButtonVariant } from "@/enums"; +import { DATE_SORT_OPTIONS } from "@/features/base-models/utils/common"; + +type TMenuItem = { + value: string; + apiValue: string; +}; + +type MobileBaseModelFiltersDialogProps = { + isOpened: boolean; + closeDialog: () => void; + categoryMenuItems: TMenuItem[]; + dateMenuItems: TMenuItem[]; + selectedCategoryLabel: string; + selectedDateLabel: string; + setCategory: (value: string | null) => void; + setDateSort: (value: string | null) => void; +}; + +const FilterItem = ({ + children, + title, +}: { + title: string; + children: React.ReactNode; +}) => { + return ( +
+

{title}

+
{children}
+
+ ); +}; + +const MobileBaseModelFiltersDialog: React.FC< + MobileBaseModelFiltersDialogProps +> = ({ + isOpened, + closeDialog, + categoryMenuItems, + dateMenuItems, + selectedCategoryLabel, + selectedDateLabel, + setCategory, + setDateSort, +}) => { + return ( + +
+ {/* Sort */} + + { + const selected = DATE_SORT_OPTIONS.find((d) => d.label === value); + + if (selected) { + setDateSort( + selected.value === "newest" ? null : selected.value, + ); + } + }} + defaultSelectedItem={selectedDateLabel} + triggerComponent={ +

+ {selectedDateLabel} +

+ } + /> +
+ + {/* Category */} + + { + const selected = categoryMenuItems.find((c) => c.value === value); + + if (selected) { + setCategory( + selected.apiValue === "all" ? null : selected.apiValue, + ); + } + }} + defaultSelectedItem={selectedCategoryLabel} + triggerComponent={ +

+ {selectedCategoryLabel} +

+ } + /> +
+ + {/* Footer */} +
+ +
+
+
+ ); +}; + +export default MobileBaseModelFiltersDialog; diff --git a/frontend/src/features/base-models/components/model-extent-map.tsx b/frontend/src/features/base-models/components/model-extent-map.tsx new file mode 100644 index 000000000..727a8853c --- /dev/null +++ b/frontend/src/features/base-models/components/model-extent-map.tsx @@ -0,0 +1,77 @@ +import { MapComponent } from "@/components/map"; +import { useMapInstance } from "@/hooks/use-map-instance"; +import { LngLatBoundsLike } from "maplibre-gl"; +import { useEffect, useMemo } from "react"; + +type TModelExtentMapProps = { + /** [minLng, minLat, maxLng, maxLat] */ + bbox: [number, number, number, number]; +}; + +const SOURCE_ID = "model-extent"; +const FILL_ID = "model-extent-fill"; +const LINE_ID = "model-extent-outline"; +const MAX_LAT = 85.051129; // Web Mercator limit +const COLOR = "#2563EB"; + +export const ModelExtentMap = ({ bbox }: TModelExtentMapProps) => { + const { mapContainerRef, map } = useMapInstance(); + + // Clamp latitude once; derive corners from the result. + const [minLng, minLat, maxLng, maxLat] = useMemo(() => { + const [w, s, e, n] = bbox; + return [w, Math.max(s, -MAX_LAT), e, Math.min(n, MAX_LAT)]; + }, [bbox]); + + useEffect(() => { + if (!map) return; + + const bounds: LngLatBoundsLike = [[minLng, minLat], [maxLng, maxLat]]; + map.setMinZoom(0); // allow the whole world to fit this container + map.fitBounds(bounds, { animate: false, padding: 0 }); + + map.once("idle", () => { + if (map.getSource(SOURCE_ID)) return; + map.addSource(SOURCE_ID, { + type: "geojson", + data: { + type: "Feature", + properties: {}, + geometry: { + type: "Polygon", + coordinates: [[ + [minLng, minLat], [maxLng, minLat], + [maxLng, maxLat], [minLng, maxLat], [minLng, minLat], + ]], + }, + }, + }); + map.addLayer({ + id: FILL_ID, + type: "fill", + source: SOURCE_ID, + paint: { "fill-color": COLOR, "fill-opacity": 0.12 }, + }); + map.addLayer({ + id: LINE_ID, + type: "line", + source: SOURCE_ID, + paint: { "line-color": COLOR, "line-width": 3 }, + }); + map.triggerRepaint(); + }); + + return () => { + if (!map.getStyle()) return; + if (map.getLayer(LINE_ID)) map.removeLayer(LINE_ID); + if (map.getLayer(FILL_ID)) map.removeLayer(FILL_ID); + if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID); + }; + }, [map, minLng, minLat, maxLng, maxLat]); + + return ( +
+ +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/features/base-models/hooks/use-base-models.ts b/frontend/src/features/base-models/hooks/use-base-models.ts new file mode 100644 index 000000000..0a6d655b5 --- /dev/null +++ b/frontend/src/features/base-models/hooks/use-base-models.ts @@ -0,0 +1,43 @@ +import { useQuery } from "@tanstack/react-query"; +import { getBaseModelById, getBaseModels } from "@/features/base-models/api/get-base-models"; +import { + mapStacItemToBaseModel, + mapStacItemToBaseModelDetail, +} from "@/features/base-models/utils/stac"; + +export const useBaseModels = () => { + return useQuery({ + queryKey: ["base-models"], + queryFn: async () => { + const data = await getBaseModels(); + return data.features.map(mapStacItemToBaseModel); + }, + }); +}; + +export const useBaseModel = (id?: string) => { + return useQuery({ + queryKey: ["base-model", id], + queryFn: async () => { + const data = await getBaseModelById(id as string); + const model = mapStacItemToBaseModelDetail(data); + + // markdownContent is a URL — fetch the actual text content + if (model.markdownContent) { + try { + const res = await fetch(model.markdownContent); + if (res.ok) { + model.markdownContent = await res.text(); + } else { + model.markdownContent = ""; + } + } catch { + model.markdownContent = ""; + } + } + + return model; + }, + enabled: !!id, + }); +}; 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..955e400b4 --- /dev/null +++ b/frontend/src/features/base-models/layouts/grid.tsx @@ -0,0 +1,20 @@ +import { TBaseModel } from "@/types"; +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..0f0c5bcf4 --- /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 { truncateString } from "@/utils"; +import { useNavigate } from "react-router-dom"; +import { useState } from "react"; +import { TBaseModel } from "@/types"; + +const columnDefinitions: ColumnDef[] = [ + { + accessorKey: "id", + header: ({ column }) => , + }, + { + accessorKey: "name", + header: "Model Name", + cell: ({ row }) => ( + + {truncateString(row.getValue("name"), 50)} + + ), + }, + { + accessorKey: "task", + header: "Task", + }, + { + accessorKey: "author", + 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/features/base-models/utils/common.ts b/frontend/src/features/base-models/utils/common.ts new file mode 100644 index 000000000..7d301355f --- /dev/null +++ b/frontend/src/features/base-models/utils/common.ts @@ -0,0 +1,4 @@ +export const DATE_SORT_OPTIONS = [ + { label: "Newest", value: "newest" }, + { label: "Oldest", value: "oldest" }, +]; diff --git a/frontend/src/features/base-models/utils/stac.ts b/frontend/src/features/base-models/utils/stac.ts new file mode 100644 index 000000000..64ed62e38 --- /dev/null +++ b/frontend/src/features/base-models/utils/stac.ts @@ -0,0 +1,136 @@ +import { TBaseModel } from "@/types"; + +export const mapStacItemToBaseModel = (feature: any): TBaseModel => { + const props = feature.properties; + + const provider = + props.providers?.find((p: any) => p.roles?.includes("producer")) || + props.providers?.[0]; + return { + id: feature.id, + + name: props.title || props["mlm:name"], + + description: props.description || "", + + author: provider?.name || "Unknown", + + task: props?.keywords?.[0] || "unknown", + + keywords: props?.keywords ?? [], + + version: props.version || "1", + + lastModified: new Date(props.updated).toLocaleDateString(), + + accuracy: extractAccuracy(props), + }; +}; + +export const mapStacItemToBaseModelDetail = (item: any) => { + const p = item.properties ?? {}; + const assets = item.assets ?? {}; + const getAsset = (key: string) => assets[key]; + return { + id: item.id, + + // header + fullTitle: p.title, + + dataId: item.id, + + // geographic extent [minLng, minLat, maxLng, maxLat] + bbox: (item.bbox ?? null) as [number, number, number, number] | null, + + // metadata + createdBy: + p.providers?.find((p: any) => p.roles?.includes("producer"))?.name ?? + "Unknown", + + generatedOn: p.created, + lastModified: p.updated, + version: p.version, + dataDatetime: p.datetime, + + modelWeightsLicense: p.license, + datasetLicense: p.license, + + task: (p.keywords ?? [])[0] ?? "unknown", + keywords: p.keywords ?? [], + accuracy: p["fair:metrics_spec"] ?? null, + + markdownContent: getAsset("readme")?.href, + readmeUrl: getAsset("readme")?.href, + + dataInfo: { + sensor: p.mlm?.input?.[0]?.name ?? "Unknown", + crs: "EPSG:4326", + spatialExtent: "Global", + temporalExtent: `${p.created} → ${p.updated}`, + }, + + architecture: { + baseModel: p["mlm:name"], + architecture: p["mlm:architecture"], + framework: p["mlm:framework"], + pretrained: p["mlm:pretrained"] != null ? (p["mlm:pretrained"] ? "Yes" : "No") : undefined, + accelerator: p["mlm:accelerator"], + cpuRequest: p["fair:cpu_request"], + memoryLimit: p["fair:memory_limit"], + acceleratorCount: p["mlm:accelerator_count"] != null ? String(p["mlm:accelerator_count"]) : undefined, + frameworkVersion: p["mlm:framework_version"], + pretrainedSource: p["mlm:pretrained_source"], + tileSizePx: "640", + processing: "preprocess pipeline", + resize: "640x640", + scaling: "0–1 normalization", + outputDescription: p.description, + variants: [], + }, + + + mlmTasks: (p["mlm:tasks"] ?? []) as string[], + + mlmInput: (p["mlm:input"] ?? []) as { + name: string; + bands: { name: string }[]; + input: { shape: number[]; data_type: string; dim_order: string[] }; + pre_processing_function?: { format: string; expression: string }; + }[], + + mlmOutput: (p["mlm:output"] ?? []) as { + name: string; + bands: { name: string }[]; + tasks: string[]; + result: { shape: number[]; data_type: string; dim_order: string[] }; + "classification:classes"?: { name: string; value: number; description: string }[]; + post_processing_function?: { format: string; expression: string }; + }[], + + assets: Object.entries(assets).map(([key, value]: any) => ({ + key, + href: value.href, + type: value.type, + title: value.title, + roles: value.roles, + })), + }; +}; + +const extractAccuracy = (properties: any): number => { + const metrics = properties["fair:metrics_spec"]; + + if (!metrics?.length) { + return 0; + } + + const accuracyMetric = metrics.find( + (metric: any) => metric.key === "fair:accuracy", + ); + + if (!accuracyMetric) { + return 0; + } + + return 0; +}; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index f9b68a3fb..53252d5e8 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -354,6 +354,7 @@ export type TBaseModel = { author: string; lastModified: string; task: string; + keywords: string[]; version: string; }; diff --git a/frontend/src/types/ui-contents.ts b/frontend/src/types/ui-contents.ts index 06a4b5eef..44265ad5f 100644 --- a/frontend/src/types/ui-contents.ts +++ b/frontend/src/types/ui-contents.ts @@ -432,6 +432,44 @@ 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; + + 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: { From 7c2bb098270be6f76ca0a2e339a7b888e0b930b7 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 16 Jun 2026 10:50:11 +0100 Subject: [PATCH 02/21] feat: restore base model --- frontend/src/app/router.tsx | 2 +- .../routes/base-models/base-model-detail.tsx | 273 +++++++++++------- .../routes/base-models/base-models-list.tsx | 5 +- frontend/src/assets/images/index.ts | 1 - frontend/src/constants/routes.ts | 1 - .../constants/ui-contents/shared-content.ts | 6 +- .../components/base-model-card.tsx | 3 - .../components/base-model-detail-skeleton.tsx | 6 +- .../components/base-model-keywords.tsx | 2 - .../components/model-extent-map.tsx | 27 +- .../base-models/hooks/use-base-models.ts | 5 +- .../features/base-models/layouts/table.tsx | 2 +- .../src/features/base-models/utils/stac.ts | 19 +- frontend/src/types/ui-contents.ts | 5 +- 14 files changed, 212 insertions(+), 145 deletions(-) diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 4ece434c1..b43625166 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -340,7 +340,7 @@ const router = createBrowserRouter([ /** * Start mapping route ends. */ - /** + /** * Base Models routes. */ { 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 fc4c01859..0aae6ec25 100644 --- a/frontend/src/app/routes/base-models/base-model-detail.tsx +++ b/frontend/src/app/routes/base-models/base-model-detail.tsx @@ -12,7 +12,11 @@ import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useBaseModel } from "@/features/base-models/hooks/use-base-models"; import MarkdownViewer from "@/components/shared/markdown-render"; -import { BaseModelDetailSkeleton, BaseModelKeywords, ModelExtentMap } from "@/features/base-models/components"; +import { + BaseModelDetailSkeleton, + BaseModelKeywords, + ModelExtentMap, +} from "@/features/base-models/components"; import { formatDate } from "@/utils"; type TInfoRowConfig = { @@ -94,7 +98,6 @@ const InfoRow = ({
); - export const BaseModelDetailPage = () => { const { id } = useParams(); const navigate = useNavigate(); @@ -111,127 +114,174 @@ export const BaseModelDetailPage = () => { const architectureRows: TInfoRowConfig[] = model ? [ - { label: "Base Model", value: model.architecture.baseModel }, - { label: "Architecture", value: model.architecture.architecture }, - { label: "Framework", value: model.architecture.framework }, - { label: "Framework Version", value: model.architecture.frameworkVersion }, - { label: "Pretrained", value: model.architecture.pretrained }, - { label: "Pretrained Source", value: model.architecture.pretrainedSource }, - { label: "Accelerator", value: model.architecture.accelerator }, - { label: "Accelerator Count", value: model.architecture.acceleratorCount }, - { label: "CPU Request", value: model.architecture.cpuRequest }, - { label: "Memory Limit", value: model.architecture.memoryLimit }, - { 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: "Description", - value: model.architecture.outputDescription, - tooltip: "Description of the model output", - }, - ] + { label: "Base Model", value: model.architecture.baseModel }, + { label: "Architecture", value: model.architecture.architecture }, + { label: "Framework", value: model.architecture.framework }, + { + label: "Framework Version", + value: model.architecture.frameworkVersion, + }, + { label: "Pretrained", value: model.architecture.pretrained }, + { + label: "Pretrained Source", + value: model.architecture.pretrainedSource, + }, + { label: "Accelerator", value: model.architecture.accelerator }, + { + label: "Accelerator Count", + value: model.architecture.acceleratorCount, + }, + { label: "CPU Request", value: model.architecture.cpuRequest }, + { label: "Memory Limit", value: model.architecture.memoryLimit }, + { 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: "Description", + value: model.architecture.outputDescription, + tooltip: "Description of the model output", + }, + ] : []; const mlmRows: TInfoRowConfig[] = model ? [ - { label: "Tasks", value: model.mlmTasks.join(", ") }, - // Input - ...(model.mlmInput[0] - ? [ - { label: "Input Name", value: model.mlmInput[0].name }, - { - label: "Input Bands", - value: model.mlmInput[0].bands.map((b: { name: string }) => b.name).join(", "), - }, - { label: "Input Shape", value: model.mlmInput[0].input.shape.join(" × ") }, - { label: "Input Data Type", value: model.mlmInput[0].input.data_type }, - { label: "Input Dim Order", value: model.mlmInput[0].input.dim_order.join(", ") }, - ...(model.mlmInput[0].pre_processing_function - ? [{ label: "Pre-processing", value: model.mlmInput[0].pre_processing_function.expression }] - : []), - ] - : []), - // Output - ...(model.mlmOutput[0] - ? [ - { label: "Output Name", value: model.mlmOutput[0].name }, - { - label: "Output Bands", - value: - model.mlmOutput[0].bands.length > 0 - ? model.mlmOutput[0].bands.map((b: { name: string }) => b.name).join(", ") - : "n/a", - }, - { label: "Output Tasks", value: model.mlmOutput[0].tasks.join(", ") }, - { label: "Output Shape", value: model.mlmOutput[0].result.shape.join(" × ") }, - { label: "Output Data Type", value: model.mlmOutput[0].result.data_type }, - { label: "Output Dim Order", value: model.mlmOutput[0].result.dim_order.join(", ") }, - ...(model.mlmOutput[0]["classification:classes"]?.length - ? [ + { label: "Tasks", value: model.mlmTasks.join(", ") }, + // Input + ...(model.mlmInput[0] + ? [ + { label: "Input Name", value: model.mlmInput[0].name }, { - label: "Classes", - value: model.mlmOutput[0]["classification:classes"] - .map((c: { name: string; value: number }) => `${c.name} (${c.value})`) + label: "Input Bands", + value: model.mlmInput[0].bands + .map((b: { name: string }) => b.name) .join(", "), }, + { + label: "Input Shape", + value: model.mlmInput[0].input.shape.join(" × "), + }, + { + label: "Input Data Type", + value: model.mlmInput[0].input.data_type, + }, + { + label: "Input Dim Order", + value: model.mlmInput[0].input.dim_order.join(", "), + }, + ...(model.mlmInput[0].pre_processing_function + ? [ + { + label: "Pre-processing", + value: + model.mlmInput[0].pre_processing_function.expression, + }, + ] + : []), + ] + : []), + // Output + ...(model.mlmOutput[0] + ? [ + { label: "Output Name", value: model.mlmOutput[0].name }, + { + label: "Output Bands", + value: + model.mlmOutput[0].bands.length > 0 + ? model.mlmOutput[0].bands + .map((b: { name: string }) => b.name) + .join(", ") + : "n/a", + }, + { + label: "Output Tasks", + value: model.mlmOutput[0].tasks.join(", "), + }, + { + label: "Output Shape", + value: model.mlmOutput[0].result.shape.join(" × "), + }, + { + label: "Output Data Type", + value: model.mlmOutput[0].result.data_type, + }, + { + label: "Output Dim Order", + value: model.mlmOutput[0].result.dim_order.join(", "), + }, + ...(model.mlmOutput[0]["classification:classes"]?.length + ? [ + { + label: "Classes", + value: model.mlmOutput[0]["classification:classes"] + .map( + (c: { name: string; value: number }) => + `${c.name} (${c.value})`, + ) + .join(", "), + }, + ] + : []), + ...(model.mlmOutput[0].post_processing_function + ? [ + { + label: "Post-processing", + value: + model.mlmOutput[0].post_processing_function.expression, + }, + ] + : []), ] - : []), - ...(model.mlmOutput[0].post_processing_function - ? [{ label: "Post-processing", value: model.mlmOutput[0].post_processing_function.expression }] - : []), - ] - : []), - ].filter((row) => row.value != null && row.value !== "") + : []), + ].filter((row) => row.value != null && row.value !== "") : []; 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", - }, - ] + { + 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", + }, + ] : []; - - const generalInfoRows: TInfoRowConfig[] = model ? [ - { label: "Created", value: formatDate(model.generatedOn) }, - { label: "License", value: model.modelWeightsLicense }, - { label: "Updated", value: formatDate(model.lastModified) }, - { label: "Data Version", value: model.version }, - { label: "Time of Data", value: formatDate(model.dataDatetime) }, - ].filter((row) => row.value != null && row.value !== "") + { label: "Created", value: formatDate(model.generatedOn) }, + { label: "License", value: model.modelWeightsLicense }, + { label: "Updated", value: formatDate(model.lastModified) }, + { label: "Data Version", value: model.version }, + { label: "Time of Data", value: formatDate(model.dataDatetime) }, + ].filter((row) => row.value != null && row.value !== "") : []; if (!model) { @@ -282,7 +332,10 @@ export const BaseModelDetailPage = () => { />
Task: - +
{
{/* Right: map extent — justified to the end */} - {model.bbox ? ( - - ) : null} + {model.bbox ? : null}
{/* Download Metadata Link */} 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 ae9d71b78..49673b82b 100644 --- a/frontend/src/app/routes/base-models/base-models-list.tsx +++ b/frontend/src/app/routes/base-models/base-models-list.tsx @@ -133,8 +133,9 @@ export const BaseModelsPage = () => { return (
-

No base models found

- +

+ No base models found +

); } diff --git a/frontend/src/assets/images/index.ts b/frontend/src/assets/images/index.ts index 155cb247e..93e19f1a1 100644 --- a/frontend/src/assets/images/index.ts +++ b/frontend/src/assets/images/index.ts @@ -17,4 +17,3 @@ export { default as AdvancedCourseImage } from "@/assets/images/advanced_course. export { default as UpdateCoverImage } from "@/assets/images/cover.png"; export { default as fAIrSwipeIllustration } from "@/assets/images/fairswipe_illustration.png"; export { default as BaseModelCTAImage } from "@/assets/images/base_model_cta_image.png"; - diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index fbbe9725d..f2ad94add 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -68,7 +68,6 @@ export const APPLICATION_ROUTES = { // Published AI Predictions AI_PREDICTIONS: "/ai-predictions", - // base-model start BASE_MODELS_HOME: "/base-models", BASE_MODEL_DETAILS: "/base-models/:id", diff --git a/frontend/src/constants/ui-contents/shared-content.ts b/frontend/src/constants/ui-contents/shared-content.ts index 97b946593..c63d80812 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: { + baseModelCTA: { 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.", @@ -147,7 +147,7 @@ export const SHARED_CONTENT: TSharedContent = { ctaLink: "/base-models", }, }, - + pageNotFound: { messages: { constant: "Oh sorry,", @@ -161,7 +161,7 @@ export const SHARED_CONTENT: TSharedContent = { pageNotFound: "go to homepage", }, }, - baseModelsPage: { + 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.", 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 8866ce250..944974dc6 100644 --- a/frontend/src/features/base-models/components/base-model-card.tsx +++ b/frontend/src/features/base-models/components/base-model-card.tsx @@ -24,8 +24,6 @@ const BaseModelCard: React.FC = ({ model }) => { {model.description}

- - {/* Author & Date */}

@@ -42,4 +40,3 @@ const BaseModelCard: React.FC = ({ model }) => { }; export default BaseModelCard; - diff --git a/frontend/src/features/base-models/components/base-model-detail-skeleton.tsx b/frontend/src/features/base-models/components/base-model-detail-skeleton.tsx index e6df3adfb..0c6294867 100644 --- a/frontend/src/features/base-models/components/base-model-detail-skeleton.tsx +++ b/frontend/src/features/base-models/components/base-model-detail-skeleton.tsx @@ -1,8 +1,4 @@ -const SkeletonBlock = ({ - className = "", -}: { - className?: string; -}) => ( +const SkeletonBlock = ({ className = "" }: { className?: string }) => (

); diff --git a/frontend/src/features/base-models/components/base-model-keywords.tsx b/frontend/src/features/base-models/components/base-model-keywords.tsx index 3cdee235c..ba129b6d6 100644 --- a/frontend/src/features/base-models/components/base-model-keywords.tsx +++ b/frontend/src/features/base-models/components/base-model-keywords.tsx @@ -15,7 +15,6 @@ export const BaseModelKeywords = ({ const visible = sanitized.slice(0, visibleLimit); - return (
{visible.map((keyword) => ( @@ -26,7 +25,6 @@ export const BaseModelKeywords = ({ {keyword} ))} -
); }; diff --git a/frontend/src/features/base-models/components/model-extent-map.tsx b/frontend/src/features/base-models/components/model-extent-map.tsx index 727a8853c..ed8445596 100644 --- a/frontend/src/features/base-models/components/model-extent-map.tsx +++ b/frontend/src/features/base-models/components/model-extent-map.tsx @@ -26,7 +26,10 @@ export const ModelExtentMap = ({ bbox }: TModelExtentMapProps) => { useEffect(() => { if (!map) return; - const bounds: LngLatBoundsLike = [[minLng, minLat], [maxLng, maxLat]]; + const bounds: LngLatBoundsLike = [ + [minLng, minLat], + [maxLng, maxLat], + ]; map.setMinZoom(0); // allow the whole world to fit this container map.fitBounds(bounds, { animate: false, padding: 0 }); @@ -39,10 +42,15 @@ export const ModelExtentMap = ({ bbox }: TModelExtentMapProps) => { properties: {}, geometry: { type: "Polygon", - coordinates: [[ - [minLng, minLat], [maxLng, minLat], - [maxLng, maxLat], [minLng, maxLat], [minLng, minLat], - ]], + coordinates: [ + [ + [minLng, minLat], + [maxLng, minLat], + [maxLng, maxLat], + [minLng, maxLat], + [minLng, minLat], + ], + ], }, }, }); @@ -71,7 +79,12 @@ export const ModelExtentMap = ({ bbox }: TModelExtentMapProps) => { return (
- +
); -}; \ No newline at end of file +}; diff --git a/frontend/src/features/base-models/hooks/use-base-models.ts b/frontend/src/features/base-models/hooks/use-base-models.ts index 0a6d655b5..c4733cdbc 100644 --- a/frontend/src/features/base-models/hooks/use-base-models.ts +++ b/frontend/src/features/base-models/hooks/use-base-models.ts @@ -1,5 +1,8 @@ import { useQuery } from "@tanstack/react-query"; -import { getBaseModelById, getBaseModels } from "@/features/base-models/api/get-base-models"; +import { + getBaseModelById, + getBaseModels, +} from "@/features/base-models/api/get-base-models"; import { mapStacItemToBaseModel, mapStacItemToBaseModelDetail, diff --git a/frontend/src/features/base-models/layouts/table.tsx b/frontend/src/features/base-models/layouts/table.tsx index 0f0c5bcf4..bac7b13dc 100644 --- a/frontend/src/features/base-models/layouts/table.tsx +++ b/frontend/src/features/base-models/layouts/table.tsx @@ -2,7 +2,7 @@ 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 { truncateString } from "@/utils"; +import { truncateString } from "@/utils"; import { useNavigate } from "react-router-dom"; import { useState } from "react"; import { TBaseModel } from "@/types"; diff --git a/frontend/src/features/base-models/utils/stac.ts b/frontend/src/features/base-models/utils/stac.ts index 64ed62e38..969a3c854 100644 --- a/frontend/src/features/base-models/utils/stac.ts +++ b/frontend/src/features/base-models/utils/stac.ts @@ -73,11 +73,19 @@ export const mapStacItemToBaseModelDetail = (item: any) => { baseModel: p["mlm:name"], architecture: p["mlm:architecture"], framework: p["mlm:framework"], - pretrained: p["mlm:pretrained"] != null ? (p["mlm:pretrained"] ? "Yes" : "No") : undefined, + pretrained: + p["mlm:pretrained"] != null + ? p["mlm:pretrained"] + ? "Yes" + : "No" + : undefined, accelerator: p["mlm:accelerator"], cpuRequest: p["fair:cpu_request"], memoryLimit: p["fair:memory_limit"], - acceleratorCount: p["mlm:accelerator_count"] != null ? String(p["mlm:accelerator_count"]) : undefined, + acceleratorCount: + p["mlm:accelerator_count"] != null + ? String(p["mlm:accelerator_count"]) + : undefined, frameworkVersion: p["mlm:framework_version"], pretrainedSource: p["mlm:pretrained_source"], tileSizePx: "640", @@ -88,7 +96,6 @@ export const mapStacItemToBaseModelDetail = (item: any) => { variants: [], }, - mlmTasks: (p["mlm:tasks"] ?? []) as string[], mlmInput: (p["mlm:input"] ?? []) as { @@ -103,7 +110,11 @@ export const mapStacItemToBaseModelDetail = (item: any) => { bands: { name: string }[]; tasks: string[]; result: { shape: number[]; data_type: string; dim_order: string[] }; - "classification:classes"?: { name: string; value: number; description: string }[]; + "classification:classes"?: { + name: string; + value: number; + description: string; + }[]; post_processing_function?: { format: string; expression: string }; }[], diff --git a/frontend/src/types/ui-contents.ts b/frontend/src/types/ui-contents.ts index 44265ad5f..924058f69 100644 --- a/frontend/src/types/ui-contents.ts +++ b/frontend/src/types/ui-contents.ts @@ -432,15 +432,14 @@ export type TSharedContent = { ctaLink: string; paragraph: string; }; - baseModelCTA: { + baseModelCTA: { title: string; description: string; ctaButton: string; ctaLink: string; }; - }; - baseModelsPage: { + baseModelsPage: { pageHeadingTitle: string; pageHeadingDescription: string; pageHeadingButtonText: string; From 5d8577d430f9d1f600402faea462e335522242f2 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Wed, 17 Jun 2026 11:46:36 +0100 Subject: [PATCH 03/21] chore: correct titles --- frontend/src/app/routes/base-models/base-model-detail.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 0aae6ec25..e3739efe4 100644 --- a/frontend/src/app/routes/base-models/base-model-detail.tsx +++ b/frontend/src/app/routes/base-models/base-model-detail.tsx @@ -331,7 +331,7 @@ export const BaseModelDetailPage = () => { value={model.datasetLicense} />
- Task: + Tasks: { {/* Right Column - Architecture Info */}
- +
{architectureRows.map((row) => ( {
- +
{dataInfoRows.map((row) => ( Date: Wed, 17 Jun 2026 11:48:03 +0100 Subject: [PATCH 04/21] chore: correct titles --- frontend/src/app/routes/base-models/base-model-detail.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 e3739efe4..fc6589bf6 100644 --- a/frontend/src/app/routes/base-models/base-model-detail.tsx +++ b/frontend/src/app/routes/base-models/base-model-detail.tsx @@ -373,7 +373,10 @@ export const BaseModelDetailPage = () => { {/* Right Column - Architecture Info */}
- +
{architectureRows.map((row) => ( Date: Fri, 19 Jun 2026 09:47:51 +0100 Subject: [PATCH 05/21] resolvetable --- .../routes/base-models/base-model-detail.tsx | 463 +++++++++--------- .../components/model-extent-map.tsx | 2 +- 2 files changed, 236 insertions(+), 229 deletions(-) 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 fc6589bf6..0797d4b5a 100644 --- a/frontend/src/app/routes/base-models/base-model-detail.tsx +++ b/frontend/src/app/routes/base-models/base-model-detail.tsx @@ -114,174 +114,174 @@ export const BaseModelDetailPage = () => { const architectureRows: TInfoRowConfig[] = model ? [ - { label: "Base Model", value: model.architecture.baseModel }, - { label: "Architecture", value: model.architecture.architecture }, - { label: "Framework", value: model.architecture.framework }, - { - label: "Framework Version", - value: model.architecture.frameworkVersion, - }, - { label: "Pretrained", value: model.architecture.pretrained }, - { - label: "Pretrained Source", - value: model.architecture.pretrainedSource, - }, - { label: "Accelerator", value: model.architecture.accelerator }, - { - label: "Accelerator Count", - value: model.architecture.acceleratorCount, - }, - { label: "CPU Request", value: model.architecture.cpuRequest }, - { label: "Memory Limit", value: model.architecture.memoryLimit }, - { 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: "Description", - value: model.architecture.outputDescription, - tooltip: "Description of the model output", - }, - ] + { label: "Base Model", value: model.architecture.baseModel }, + { label: "Architecture", value: model.architecture.architecture }, + { label: "Framework", value: model.architecture.framework }, + { + label: "Framework Version", + value: model.architecture.frameworkVersion, + }, + { label: "Pretrained", value: model.architecture.pretrained }, + { + label: "Pretrained Source", + value: model.architecture.pretrainedSource, + }, + { label: "Accelerator", value: model.architecture.accelerator }, + { + label: "Accelerator Count", + value: model.architecture.acceleratorCount, + }, + { label: "CPU Request", value: model.architecture.cpuRequest }, + { label: "Memory Limit", value: model.architecture.memoryLimit }, + { 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: "Description", + value: model.architecture.outputDescription, + tooltip: "Description of the model output", + }, + ] : []; const mlmRows: TInfoRowConfig[] = model ? [ - { label: "Tasks", value: model.mlmTasks.join(", ") }, - // Input - ...(model.mlmInput[0] - ? [ - { label: "Input Name", value: model.mlmInput[0].name }, + { label: "Tasks", value: model.mlmTasks.join(", ") }, + // Input + ...(model.mlmInput[0] + ? [ + { label: "Input Name", value: model.mlmInput[0].name }, + { + label: "Input Bands", + value: model.mlmInput[0].bands + .map((b: { name: string }) => b.name) + .join(", "), + }, + { + label: "Input Shape", + value: model.mlmInput[0].input.shape.join(" × "), + }, + { + label: "Input Data Type", + value: model.mlmInput[0].input.data_type, + }, + { + label: "Input Dim Order", + value: model.mlmInput[0].input.dim_order.join(", "), + }, + ...(model.mlmInput[0].pre_processing_function + ? [ { - label: "Input Bands", - value: model.mlmInput[0].bands - .map((b: { name: string }) => b.name) - .join(", "), - }, - { - label: "Input Shape", - value: model.mlmInput[0].input.shape.join(" × "), - }, - { - label: "Input Data Type", - value: model.mlmInput[0].input.data_type, + label: "Pre-processing", + value: + model.mlmInput[0].pre_processing_function.expression, }, + ] + : []), + ] + : []), + // Output + ...(model.mlmOutput[0] + ? [ + { label: "Output Name", value: model.mlmOutput[0].name }, + { + label: "Output Bands", + value: + model.mlmOutput[0].bands.length > 0 + ? model.mlmOutput[0].bands + .map((b: { name: string }) => b.name) + .join(", ") + : "n/a", + }, + { + label: "Output Tasks", + value: model.mlmOutput[0].tasks.join(", "), + }, + { + label: "Output Shape", + value: model.mlmOutput[0].result.shape.join(" × "), + }, + { + label: "Output Data Type", + value: model.mlmOutput[0].result.data_type, + }, + { + label: "Output Dim Order", + value: model.mlmOutput[0].result.dim_order.join(", "), + }, + ...(model.mlmOutput[0]["classification:classes"]?.length + ? [ { - label: "Input Dim Order", - value: model.mlmInput[0].input.dim_order.join(", "), + label: "Classes", + value: model.mlmOutput[0]["classification:classes"] + .map( + (c: { name: string; value: number }) => + `${c.name} (${c.value})`, + ) + .join(", "), }, - ...(model.mlmInput[0].pre_processing_function - ? [ - { - label: "Pre-processing", - value: - model.mlmInput[0].pre_processing_function.expression, - }, - ] - : []), ] - : []), - // Output - ...(model.mlmOutput[0] - ? [ - { label: "Output Name", value: model.mlmOutput[0].name }, + : []), + ...(model.mlmOutput[0].post_processing_function + ? [ { - label: "Output Bands", + label: "Post-processing", value: - model.mlmOutput[0].bands.length > 0 - ? model.mlmOutput[0].bands - .map((b: { name: string }) => b.name) - .join(", ") - : "n/a", - }, - { - label: "Output Tasks", - value: model.mlmOutput[0].tasks.join(", "), - }, - { - label: "Output Shape", - value: model.mlmOutput[0].result.shape.join(" × "), - }, - { - label: "Output Data Type", - value: model.mlmOutput[0].result.data_type, + model.mlmOutput[0].post_processing_function.expression, }, - { - label: "Output Dim Order", - value: model.mlmOutput[0].result.dim_order.join(", "), - }, - ...(model.mlmOutput[0]["classification:classes"]?.length - ? [ - { - label: "Classes", - value: model.mlmOutput[0]["classification:classes"] - .map( - (c: { name: string; value: number }) => - `${c.name} (${c.value})`, - ) - .join(", "), - }, - ] - : []), - ...(model.mlmOutput[0].post_processing_function - ? [ - { - label: "Post-processing", - value: - model.mlmOutput[0].post_processing_function.expression, - }, - ] - : []), ] - : []), - ].filter((row) => row.value != null && row.value !== "") + : []), + ] + : []), + ].filter((row) => row.value != null && row.value !== "") : []; 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", - }, - ] + { + 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", + }, + ] : []; const generalInfoRows: TInfoRowConfig[] = model ? [ - { label: "Created", value: formatDate(model.generatedOn) }, - { label: "License", value: model.modelWeightsLicense }, - { label: "Updated", value: formatDate(model.lastModified) }, - { label: "Data Version", value: model.version }, - { label: "Time of Data", value: formatDate(model.dataDatetime) }, - ].filter((row) => row.value != null && row.value !== "") + { label: "Created", value: formatDate(model.generatedOn) }, + { label: "License", value: model.modelWeightsLicense }, + { label: "Updated", value: formatDate(model.lastModified) }, + { label: "Data Version", value: model.version }, + { label: "Time of Data", value: formatDate(model.dataDatetime) }, + ].filter((row) => row.value != null && row.value !== "") : []; if (!model) { @@ -315,37 +315,37 @@ export const BaseModelDetailPage = () => {
{/* Metadata + Map Extent — metadata on left, map on right */} -
- {/* Left: metadata */} -
- - - - - - -
- Tasks: - -
- + + + + + + + + + + +
+ Tasks: +
+ + - {/* Right: map extent — justified to the end */} - {model.bbox ? : null}
{/* Download Metadata Link */} @@ -367,66 +367,73 @@ export const BaseModelDetailPage = () => { )} {/* Main Content: Two Column Layout */} -
+
{/* Left Column - Overview */} {/* Right Column - Architecture Info */} -
- -
- {architectureRows.map((row) => ( - - ))} -
-
+
+
+

Coverage

+ {/* Right: map extent — justified to the end */} + {model.bbox ? : null} +
+
+ +
+ {architectureRows.map((row) => ( + + ))} +
+
- -
- {generalInfoRows.map((row) => ( - - ))} -
-
+ +
+ {generalInfoRows.map((row) => ( + + ))} +
+
- -
- {mlmRows.map((row) => ( - - ))} -
-
+ +
+ {mlmRows.map((row) => ( + + ))} +
+
- -
- {dataInfoRows.map((row) => ( - - ))} -
-
+ +
+ {dataInfoRows.map((row) => ( + + ))} +
+
+
diff --git a/frontend/src/features/base-models/components/model-extent-map.tsx b/frontend/src/features/base-models/components/model-extent-map.tsx index ed8445596..c73207134 100644 --- a/frontend/src/features/base-models/components/model-extent-map.tsx +++ b/frontend/src/features/base-models/components/model-extent-map.tsx @@ -78,7 +78,7 @@ export const ModelExtentMap = ({ bbox }: TModelExtentMapProps) => { }, [map, minLng, minLat, maxLng, maxLat]); return ( -
+
Date: Fri, 19 Jun 2026 09:50:19 +0100 Subject: [PATCH 06/21] resolvetable --- .../routes/base-models/base-model-detail.tsx | 309 +++++++++--------- 1 file changed, 153 insertions(+), 156 deletions(-) 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 0797d4b5a..92c672ace 100644 --- a/frontend/src/app/routes/base-models/base-model-detail.tsx +++ b/frontend/src/app/routes/base-models/base-model-detail.tsx @@ -114,174 +114,174 @@ export const BaseModelDetailPage = () => { const architectureRows: TInfoRowConfig[] = model ? [ - { label: "Base Model", value: model.architecture.baseModel }, - { label: "Architecture", value: model.architecture.architecture }, - { label: "Framework", value: model.architecture.framework }, - { - label: "Framework Version", - value: model.architecture.frameworkVersion, - }, - { label: "Pretrained", value: model.architecture.pretrained }, - { - label: "Pretrained Source", - value: model.architecture.pretrainedSource, - }, - { label: "Accelerator", value: model.architecture.accelerator }, - { - label: "Accelerator Count", - value: model.architecture.acceleratorCount, - }, - { label: "CPU Request", value: model.architecture.cpuRequest }, - { label: "Memory Limit", value: model.architecture.memoryLimit }, - { 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: "Description", - value: model.architecture.outputDescription, - tooltip: "Description of the model output", - }, - ] + { label: "Base Model", value: model.architecture.baseModel }, + { label: "Architecture", value: model.architecture.architecture }, + { label: "Framework", value: model.architecture.framework }, + { + label: "Framework Version", + value: model.architecture.frameworkVersion, + }, + { label: "Pretrained", value: model.architecture.pretrained }, + { + label: "Pretrained Source", + value: model.architecture.pretrainedSource, + }, + { label: "Accelerator", value: model.architecture.accelerator }, + { + label: "Accelerator Count", + value: model.architecture.acceleratorCount, + }, + { label: "CPU Request", value: model.architecture.cpuRequest }, + { label: "Memory Limit", value: model.architecture.memoryLimit }, + { 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: "Description", + value: model.architecture.outputDescription, + tooltip: "Description of the model output", + }, + ] : []; const mlmRows: TInfoRowConfig[] = model ? [ - { label: "Tasks", value: model.mlmTasks.join(", ") }, - // Input - ...(model.mlmInput[0] - ? [ - { label: "Input Name", value: model.mlmInput[0].name }, - { - label: "Input Bands", - value: model.mlmInput[0].bands - .map((b: { name: string }) => b.name) - .join(", "), - }, - { - label: "Input Shape", - value: model.mlmInput[0].input.shape.join(" × "), - }, - { - label: "Input Data Type", - value: model.mlmInput[0].input.data_type, - }, - { - label: "Input Dim Order", - value: model.mlmInput[0].input.dim_order.join(", "), - }, - ...(model.mlmInput[0].pre_processing_function - ? [ + { label: "Tasks", value: model.mlmTasks.join(", ") }, + // Input + ...(model.mlmInput[0] + ? [ + { label: "Input Name", value: model.mlmInput[0].name }, { - label: "Pre-processing", - value: - model.mlmInput[0].pre_processing_function.expression, - }, - ] - : []), - ] - : []), - // Output - ...(model.mlmOutput[0] - ? [ - { label: "Output Name", value: model.mlmOutput[0].name }, - { - label: "Output Bands", - value: - model.mlmOutput[0].bands.length > 0 - ? model.mlmOutput[0].bands + label: "Input Bands", + value: model.mlmInput[0].bands .map((b: { name: string }) => b.name) - .join(", ") - : "n/a", - }, - { - label: "Output Tasks", - value: model.mlmOutput[0].tasks.join(", "), - }, - { - label: "Output Shape", - value: model.mlmOutput[0].result.shape.join(" × "), - }, - { - label: "Output Data Type", - value: model.mlmOutput[0].result.data_type, - }, - { - label: "Output Dim Order", - value: model.mlmOutput[0].result.dim_order.join(", "), - }, - ...(model.mlmOutput[0]["classification:classes"]?.length - ? [ - { - label: "Classes", - value: model.mlmOutput[0]["classification:classes"] - .map( - (c: { name: string; value: number }) => - `${c.name} (${c.value})`, - ) .join(", "), }, + { + label: "Input Shape", + value: model.mlmInput[0].input.shape.join(" × "), + }, + { + label: "Input Data Type", + value: model.mlmInput[0].input.data_type, + }, + { + label: "Input Dim Order", + value: model.mlmInput[0].input.dim_order.join(", "), + }, + ...(model.mlmInput[0].pre_processing_function + ? [ + { + label: "Pre-processing", + value: + model.mlmInput[0].pre_processing_function.expression, + }, + ] + : []), ] - : []), - ...(model.mlmOutput[0].post_processing_function - ? [ + : []), + // Output + ...(model.mlmOutput[0] + ? [ + { label: "Output Name", value: model.mlmOutput[0].name }, { - label: "Post-processing", + label: "Output Bands", value: - model.mlmOutput[0].post_processing_function.expression, + model.mlmOutput[0].bands.length > 0 + ? model.mlmOutput[0].bands + .map((b: { name: string }) => b.name) + .join(", ") + : "n/a", + }, + { + label: "Output Tasks", + value: model.mlmOutput[0].tasks.join(", "), + }, + { + label: "Output Shape", + value: model.mlmOutput[0].result.shape.join(" × "), + }, + { + label: "Output Data Type", + value: model.mlmOutput[0].result.data_type, }, + { + label: "Output Dim Order", + value: model.mlmOutput[0].result.dim_order.join(", "), + }, + ...(model.mlmOutput[0]["classification:classes"]?.length + ? [ + { + label: "Classes", + value: model.mlmOutput[0]["classification:classes"] + .map( + (c: { name: string; value: number }) => + `${c.name} (${c.value})`, + ) + .join(", "), + }, + ] + : []), + ...(model.mlmOutput[0].post_processing_function + ? [ + { + label: "Post-processing", + value: + model.mlmOutput[0].post_processing_function.expression, + }, + ] + : []), ] - : []), - ] - : []), - ].filter((row) => row.value != null && row.value !== "") + : []), + ].filter((row) => row.value != null && row.value !== "") : []; 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", - }, - ] + { + 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", + }, + ] : []; const generalInfoRows: TInfoRowConfig[] = model ? [ - { label: "Created", value: formatDate(model.generatedOn) }, - { label: "License", value: model.modelWeightsLicense }, - { label: "Updated", value: formatDate(model.lastModified) }, - { label: "Data Version", value: model.version }, - { label: "Time of Data", value: formatDate(model.dataDatetime) }, - ].filter((row) => row.value != null && row.value !== "") + { label: "Created", value: formatDate(model.generatedOn) }, + { label: "License", value: model.modelWeightsLicense }, + { label: "Updated", value: formatDate(model.lastModified) }, + { label: "Data Version", value: model.version }, + { label: "Time of Data", value: formatDate(model.dataDatetime) }, + ].filter((row) => row.value != null && row.value !== "") : []; if (!model) { @@ -326,12 +326,8 @@ export const BaseModelDetailPage = () => { label="Model Weights License" value={model.modelWeightsLicense} /> - - - + +
Tasks: { value={model.dataId} tooltip="Unique dataset identifier" /> - -
{/* Download Metadata Link */} @@ -395,7 +389,10 @@ export const BaseModelDetailPage = () => {
- +
{generalInfoRows.map((row) => ( Date: Fri, 19 Jun 2026 10:03:45 +0100 Subject: [PATCH 07/21] increase width for coverage area --- frontend/src/app/routes/base-models/base-model-detail.tsx | 4 ++-- .../features/base-models/components/model-extent-map.tsx | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) 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 92c672ace..3bccbb499 100644 --- a/frontend/src/app/routes/base-models/base-model-detail.tsx +++ b/frontend/src/app/routes/base-models/base-model-detail.tsx @@ -321,7 +321,7 @@ export const BaseModelDetailPage = () => { - + { )} {/* Main Content: Two Column Layout */} -
+
{/* Left Column - Overview */} diff --git a/frontend/src/features/base-models/components/model-extent-map.tsx b/frontend/src/features/base-models/components/model-extent-map.tsx index c73207134..58e19094d 100644 --- a/frontend/src/features/base-models/components/model-extent-map.tsx +++ b/frontend/src/features/base-models/components/model-extent-map.tsx @@ -30,8 +30,10 @@ export const ModelExtentMap = ({ bbox }: TModelExtentMapProps) => { [minLng, minLat], [maxLng, maxLat], ]; + map.setMinZoom(0); // allow the whole world to fit this container - map.fitBounds(bounds, { animate: false, padding: 0 }); + map.setRenderWorldCopies(true); // repeat basemap + extent horizontally + map.fitBounds(bounds, { animate: false, padding: 32 }); map.once("idle", () => { if (map.getSource(SOURCE_ID)) return; @@ -78,7 +80,7 @@ export const ModelExtentMap = ({ bbox }: TModelExtentMapProps) => { }, [map, minLng, minLat, maxLng, maxLat]); return ( -
+
{ />
); -}; +}; \ No newline at end of file From adf5e96e1713013a6e646f0ae520fc72bedd65c3 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Fri, 19 Jun 2026 20:56:18 +0100 Subject: [PATCH 08/21] chore: updated extent map --- frontend/src/app/routes/base-models/base-model-detail.tsx | 5 ++++- .../src/features/base-models/components/model-extent-map.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) 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 3bccbb499..64dfbcd55 100644 --- a/frontend/src/app/routes/base-models/base-model-detail.tsx +++ b/frontend/src/app/routes/base-models/base-model-detail.tsx @@ -321,7 +321,10 @@ export const BaseModelDetailPage = () => { - + { />
); -}; \ No newline at end of file +}; From 27d189ead6e6dc0a7940814b9e9f914f0fe9d701 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Sun, 28 Jun 2026 22:29:56 +0100 Subject: [PATCH 09/21] chore: write tests and clean for base model --- .../routes/base-models/base-model-detail.tsx | 5 - .../routes/base-models/base-models-list.tsx | 14 +- .../__tests__/base-model-card.test.tsx | 66 +++++ .../__tests__/base-model-keywords.test.tsx | 50 ++++ .../__tests__/base-models-list.test.tsx | 254 ++++++++++++++++++ .../components/base-model-keywords.tsx | 8 +- .../components/contribute-model-dialog.tsx | 2 +- .../layouts/__tests__/grid.test.tsx | 44 +++ .../layouts/__tests__/table.test.tsx | 65 +++++ .../utils/__tests__/common.test.ts | 16 ++ .../base-models/utils/__tests__/stac.test.ts | 212 +++++++++++++++ .../src/features/base-models/utils/stac.ts | 10 +- 12 files changed, 722 insertions(+), 24 deletions(-) create mode 100644 frontend/src/features/base-models/components/__tests__/base-model-card.test.tsx create mode 100644 frontend/src/features/base-models/components/__tests__/base-model-keywords.test.tsx create mode 100644 frontend/src/features/base-models/components/__tests__/base-models-list.test.tsx create mode 100644 frontend/src/features/base-models/layouts/__tests__/grid.test.tsx create mode 100644 frontend/src/features/base-models/layouts/__tests__/table.test.tsx create mode 100644 frontend/src/features/base-models/utils/__tests__/common.test.ts create mode 100644 frontend/src/features/base-models/utils/__tests__/stac.test.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 64dfbcd55..1f7f33a31 100644 --- a/frontend/src/app/routes/base-models/base-model-detail.tsx +++ b/frontend/src/app/routes/base-models/base-model-detail.tsx @@ -7,7 +7,6 @@ import { Link } from "@/components/ui/link"; import { APPLICATION_ROUTES } from "@/constants"; import { ButtonVariant } from "@/enums"; -// import AccuracyDisplay from "@/features/models/components/accuracy-display"; import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useBaseModel } from "@/features/base-models/hooks/use-base-models"; @@ -284,10 +283,6 @@ export const BaseModelDetailPage = () => { ].filter((row) => row.value != null && row.value !== "") : []; - if (!model) { - return null; - } - 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 49673b82b..b87681d04 100644 --- a/frontend/src/app/routes/base-models/base-models-list.tsx +++ b/frontend/src/app/routes/base-models/base-models-list.tsx @@ -20,6 +20,11 @@ import { useBaseModels } from "@/features/base-models/hooks/use-base-models"; import { TBaseModel } from "@/types"; import { DATE_SORT_OPTIONS } from "@/features/base-models/utils/common"; +const DATE_MENU_ITEMS = DATE_SORT_OPTIONS.map((opt) => ({ + value: opt.label, + apiValue: opt.value, +})); + export const BaseModelsPage = () => { const { isOpened, openDialog, closeDialog } = useDialog(); @@ -84,8 +89,8 @@ export const BaseModelsPage = () => { } result.sort((a, b) => { - const aDate = new Date(a.updatedAt).getTime(); - const bDate = new Date(b.updatedAt).getTime(); + const aDate = new Date(a.lastModified).getTime(); + const bDate = new Date(b.lastModified).getTime(); if (dateSort === "oldest") return aDate - bDate; return bDate - aDate; @@ -104,10 +109,7 @@ export const BaseModelsPage = () => { })); }, [taskCategories]); - const dateMenuItems = DATE_SORT_OPTIONS.map((opt) => ({ - value: opt.label, - apiValue: opt.value, - })); + const dateMenuItems = DATE_MENU_ITEMS; const selectedCategoryLabel = taskCategories.find((c) => c.value === category)?.label || "Category"; diff --git a/frontend/src/features/base-models/components/__tests__/base-model-card.test.tsx b/frontend/src/features/base-models/components/__tests__/base-model-card.test.tsx new file mode 100644 index 000000000..0634f87b8 --- /dev/null +++ b/frontend/src/features/base-models/components/__tests__/base-model-card.test.tsx @@ -0,0 +1,66 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import BaseModelCard from "@/features/base-models/components/base-model-card"; +import { TBaseModel } from "@/types"; + +const mockModel: TBaseModel = { + id: 42, + name: "RAMP Building Detector", + description: "Detects buildings from satellite imagery using RAMP.", + author: "Humanitarian OpenStreetMap Team", + task: "building-detection", + keywords: ["building-detection", "ramp", "segmentation"], + version: "2", + lastModified: "1/15/2024", + accuracy: 0.87, +}; + +const renderCard = (model: TBaseModel = mockModel) => + render( + + + , + ); + +afterEach(cleanup); + +describe("BaseModelCard", () => { + it("renders the model name as a heading", () => { + renderCard(); + expect( + screen.getByRole("heading", { name: mockModel.name }), + ).toBeInTheDocument(); + }); + + it("renders the model description", () => { + renderCard(); + // Description can appear in tooltip/title attrs, so assert at least one element + expect(screen.getAllByText(mockModel.description)[0]).toBeInTheDocument(); + }); + + it("renders the author name", () => { + renderCard(); + expect(screen.getAllByText(mockModel.author)[0]).toBeInTheDocument(); + }); + + it("renders lastModified with label", () => { + renderCard(); + expect(screen.getAllByText(/Last Modified/i)[0]).toBeInTheDocument(); + expect(screen.getAllByText(mockModel.lastModified)[0]).toBeInTheDocument(); + }); + + it("links to the correct base model detail route", () => { + renderCard(); + const links = screen.getAllByRole("link"); + expect(links[0]).toHaveAttribute("href", `/base-models/${mockModel.id}`); + }); + + it("renders BaseModelKeywords with the model keywords", () => { + renderCard(); + // Default visibleLimit is 3 — all 3 keywords should appear + expect(screen.getAllByText("building-detection")[0]).toBeInTheDocument(); + expect(screen.getAllByText("ramp")[0]).toBeInTheDocument(); + expect(screen.getAllByText("segmentation")[0]).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/base-models/components/__tests__/base-model-keywords.test.tsx b/frontend/src/features/base-models/components/__tests__/base-model-keywords.test.tsx new file mode 100644 index 000000000..98c9b28db --- /dev/null +++ b/frontend/src/features/base-models/components/__tests__/base-model-keywords.test.tsx @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { BaseModelKeywords } from "@/features/base-models/components/base-model-keywords"; + +describe("BaseModelKeywords", () => { + it("renders nothing when keywords array is empty", () => { + const { container } = render(); + expect(container.querySelectorAll("span")).toHaveLength(0); + }); + + it("renders up to the default visible limit of 3", () => { + render( + , + ); + expect(screen.getAllByText(/alpha|beta|gamma/i)).toHaveLength(3); + expect(screen.queryByText("delta")).not.toBeInTheDocument(); + expect(screen.queryByText("epsilon")).not.toBeInTheDocument(); + }); + + it("respects a custom visibleLimit prop", () => { + render( + , + ); + expect(screen.getAllByRole("generic").length).toBeGreaterThanOrEqual(5); + ["a", "b", "c", "d", "e"].forEach((kw) => { + expect(screen.getByText(kw)).toBeInTheDocument(); + }); + }); + + it("does not render keywords beyond the visible limit", () => { + render( + , + ); + expect(screen.queryByText("hidden")).not.toBeInTheDocument(); + }); + + it("renders each keyword in its own badge span", () => { + render(); + const badges = screen.getAllByText(/roof|road/); + expect(badges).toHaveLength(2); + badges.forEach((badge) => { + expect(badge.tagName.toLowerCase()).toBe("span"); + }); + }); +}); diff --git a/frontend/src/features/base-models/components/__tests__/base-models-list.test.tsx b/frontend/src/features/base-models/components/__tests__/base-models-list.test.tsx new file mode 100644 index 000000000..7e887571b --- /dev/null +++ b/frontend/src/features/base-models/components/__tests__/base-models-list.test.tsx @@ -0,0 +1,254 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { NuqsTestingAdapter } from "nuqs/adapters/testing"; +import { BaseModelsPage } from "@/app/routes/base-models/base-models-list"; +import { TBaseModel } from "@/types"; + +// --------------------------------------------------------------------------- +// Mock heavy dependencies +// --------------------------------------------------------------------------- + +vi.mock("@/features/base-models/hooks/use-base-models", () => ({ + useBaseModels: vi.fn(), +})); + +// Mock dialog — keeps tests focused on filter/sort/layout logic +vi.mock("@/hooks/use-dialog", () => ({ + useDialog: () => ({ + isOpened: false, + openDialog: vi.fn(), + closeDialog: vi.fn(), + }), +})); + +// Stub out the skeleton/dialogs/layouts to avoid their deep dependency trees +vi.mock("@/features/base-models/components", () => ({ + BaseModelsFilters: ({ search, filteredModelsCount, setSearch }: any) => ( +
+ setSearch(e.target.value)} + /> + {filteredModelsCount} Models +
+ ), + MobileBaseModelFiltersDialog: () => null, + BaseModelListSkeleton: () =>
Loading…
, +})); + +vi.mock("@/features/base-models/layouts", () => ({ + BaseModelGridLayout: ({ models }: { models: TBaseModel[] }) => ( +
    + {models.map((m) => ( +
  • + {m.name} +
  • + ))} +
+ ), + BaseModelTableLayout: ({ models }: { models: TBaseModel[] }) => ( +
    + {models.map((m) => ( +
  • {m.name}
  • + ))} +
+ ), +})); + +vi.mock("@/features/base-models/components/contribute-model-dialog", () => ({ + default: () => null, +})); + +vi.mock("@/components/seo", () => ({ + Head: () => null, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +import { useBaseModels } from "@/features/base-models/hooks/use-base-models"; + +const mockUseBaseModels = vi.mocked(useBaseModels); + +const makeModel = ( + overrides: Partial & { id: number; name: string }, +): TBaseModel => ({ + description: "Some description", + author: "HOT", + task: "building-detection", + keywords: [], + version: "1", + lastModified: "1/1/2024", + accuracy: 0, + ...overrides, +}); + +const renderPage = (searchParams?: string) => + render( + + + + + , + ); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(cleanup); + +describe("BaseModelsPage — loading / error states", () => { + it("shows skeleton while data is loading", () => { + mockUseBaseModels.mockReturnValue({ + data: [], + isLoading: true, + isError: false, + } as any); + + renderPage(); + expect(screen.getByTestId("skeleton")).toBeInTheDocument(); + }); + + it("shows an error message when the query fails", () => { + mockUseBaseModels.mockReturnValue({ + data: [], + isLoading: false, + isError: true, + } as any); + + renderPage(); + expect(screen.getByText(/failed to load models/i)).toBeInTheDocument(); + }); + + it("shows 'No base models found' when filtered results are empty", () => { + mockUseBaseModels.mockReturnValue({ + data: [], + isLoading: false, + isError: false, + } as any); + + renderPage(); + expect(screen.getByText(/no base models found/i)).toBeInTheDocument(); + }); +}); + +describe("BaseModelsPage — search filtering", () => { + const models = [ + makeModel({ id: 1, name: "RAMP Detector", description: "Detects buildings", author: "HOT" }), + makeModel({ id: 2, name: "YOLOv8 Segmentor", description: "Fast segmentation", author: "OpenAI" }), + ]; + + beforeEach(() => { + mockUseBaseModels.mockReturnValue({ + data: models, + isLoading: false, + isError: false, + } as any); + }); + + it("shows all models when search is empty", () => { + renderPage(); + expect(screen.getByTestId("model-count")).toHaveTextContent("2 Models"); + }); + + it("filters models by name (case-insensitive)", () => { + renderPage("q=ramp"); + expect(screen.getByTestId("model-count")).toHaveTextContent("1 Models"); + expect(screen.getAllByText("RAMP Detector")).not.toHaveLength(0); + expect(screen.queryByText("YOLOv8 Segmentor")).not.toBeInTheDocument(); + }); + + it("filters models by description", () => { + renderPage("q=segmentation"); + expect(screen.getByTestId("model-count")).toHaveTextContent("1 Models"); + expect(screen.getAllByText("YOLOv8 Segmentor")).not.toHaveLength(0); + }); + + it("filters models by author", () => { + renderPage("q=openai"); + expect(screen.getByTestId("model-count")).toHaveTextContent("1 Models"); + expect(screen.getAllByText("YOLOv8 Segmentor")).not.toHaveLength(0); + }); +}); + +describe("BaseModelsPage — category filtering", () => { + const models = [ + makeModel({ id: 1, name: "Model A", task: "building-detection" }), + makeModel({ id: 2, name: "Model B", task: "road-detection" }), + ]; + + beforeEach(() => { + mockUseBaseModels.mockReturnValue({ + data: models, + isLoading: false, + isError: false, + } as any); + }); + + it("shows all models when category is 'all'", () => { + renderPage("category=all"); + expect(screen.getByTestId("model-count")).toHaveTextContent("2 Models"); + }); + + it("filters by category", () => { + renderPage("category=road-detection"); + expect(screen.getByTestId("model-count")).toHaveTextContent("1 Models"); + expect(screen.getAllByText("Model B")).not.toHaveLength(0); + expect(screen.queryByText("Model A")).not.toBeInTheDocument(); + }); +}); + +describe("BaseModelsPage — date sorting", () => { + const models = [ + makeModel({ id: 1, name: "Older Model", lastModified: "2022-01-01" }), + makeModel({ id: 2, name: "Newer Model", lastModified: "2024-06-01" }), + ]; + + beforeEach(() => { + mockUseBaseModels.mockReturnValue({ + data: models, + isLoading: false, + isError: false, + } as any); + }); + + it("sorts newest first by default", () => { + renderPage(); + const items = screen.getAllByTestId("model-item"); + expect(items[0]).toHaveTextContent("Newer Model"); + expect(items[1]).toHaveTextContent("Older Model"); + }); + + it("sorts oldest first when date=oldest", () => { + renderPage("date=oldest"); + const items = screen.getAllByTestId("model-item"); + expect(items[0]).toHaveTextContent("Older Model"); + expect(items[1]).toHaveTextContent("Newer Model"); + }); +}); + +describe("BaseModelsPage — dynamic task categories", () => { + it("derives unique task categories from model data (excluding duplicates)", () => { + mockUseBaseModels.mockReturnValue({ + data: [ + makeModel({ id: 1, name: "M1", task: "segmentation" }), + makeModel({ id: 2, name: "M2", task: "segmentation" }), // duplicate + makeModel({ id: 3, name: "M3", task: "detection" }), + ], + isLoading: false, + isError: false, + } as any); + + renderPage(); + // All 3 models should be visible — no filter applied yet + expect(screen.getByText("3 Models")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/base-models/components/base-model-keywords.tsx b/frontend/src/features/base-models/components/base-model-keywords.tsx index ba129b6d6..81d8f8328 100644 --- a/frontend/src/features/base-models/components/base-model-keywords.tsx +++ b/frontend/src/features/base-models/components/base-model-keywords.tsx @@ -7,13 +7,7 @@ export const BaseModelKeywords = ({ keywords, visibleLimit = 3, }: BaseModelKeywordsProps) => { - const sanitized = keywords.filter( - (k): k is string => !!k && k.trim().toLowerCase() !== "null", - ); - - if (sanitized.length === 0) return null; - - const visible = sanitized.slice(0, visibleLimit); + const visible = keywords.slice(0, visibleLimit); return (
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 a71dbbdc2..d4ca41dc2 100644 --- a/frontend/src/features/base-models/components/contribute-model-dialog.tsx +++ b/frontend/src/features/base-models/components/contribute-model-dialog.tsx @@ -17,7 +17,7 @@ type StepProps = { defaultOpen?: boolean; }; -const statusBadgeClasses = { +const statusBadgeClasses: Record<"pending" | "changes" | "approved", string> = { pending: "bg-status-pending-color text-grey", changes: "bg-status-changes-color text-grey", approved: "bg-green-secondary text-grey", diff --git a/frontend/src/features/base-models/layouts/__tests__/grid.test.tsx b/frontend/src/features/base-models/layouts/__tests__/grid.test.tsx new file mode 100644 index 000000000..e2d64cc82 --- /dev/null +++ b/frontend/src/features/base-models/layouts/__tests__/grid.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import BaseModelGridLayout from "@/features/base-models/layouts/grid"; +import { TBaseModel } from "@/types"; + +const makeModel = (id: number, name: string): TBaseModel => ({ + id, + name, + description: `Description for ${name}`, + author: "HOT", + task: "building-detection", + keywords: ["detection"], + version: "1", + lastModified: "1/1/2024", + accuracy: 0, +}); + +const renderGrid = (models: TBaseModel[]) => + render( + + + , + ); + +describe("BaseModelGridLayout", () => { + it("renders a card heading for each model", () => { + const models = [ + makeModel(1, "Alpha Model"), + makeModel(2, "Beta Model"), + makeModel(3, "Gamma Model"), + ]; + renderGrid(models); + expect(screen.getByRole("heading", { name: "Alpha Model" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Beta Model" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Gamma Model" })).toBeInTheDocument(); + }); + + it("renders nothing when models array is empty", () => { + const { container } = renderGrid([]); + // The grid container exists but has no link children + expect(container.querySelectorAll("a")).toHaveLength(0); + }); +}); diff --git a/frontend/src/features/base-models/layouts/__tests__/table.test.tsx b/frontend/src/features/base-models/layouts/__tests__/table.test.tsx new file mode 100644 index 000000000..05cea9b1c --- /dev/null +++ b/frontend/src/features/base-models/layouts/__tests__/table.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import BaseModelTableLayout from "@/features/base-models/layouts/table"; +import { TBaseModel } from "@/types"; + +// Mock react-router-dom's navigate +const mockNavigate = vi.fn(); +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +const makeModel = (id: number, name: string): TBaseModel => ({ + id, + name, + description: `Desc ${name}`, + author: "HOT", + task: "segmentation", + keywords: [], + version: "1", + lastModified: "6/1/2024", + accuracy: 0, +}); + +const models = [makeModel(1, "Alpha Model"), makeModel(2, "Beta Model")]; + +const renderTable = () => + render( + + + , + ); + +describe("BaseModelTableLayout", () => { + beforeEach(() => { + mockNavigate.mockReset(); + }); + + afterEach(cleanup); + it("renders column headers", () => { + renderTable(); + expect(screen.getByText("Model Name")).toBeInTheDocument(); + expect(screen.getByText("Task")).toBeInTheDocument(); + expect(screen.getByText("Created by")).toBeInTheDocument(); + expect(screen.getByText("Version")).toBeInTheDocument(); + }); + + it("renders a row for each model", () => { + renderTable(); + // Text can appear both in the cell and the title attribute, so use getAllByText + expect(screen.getAllByText("Alpha Model")).not.toHaveLength(0); + expect(screen.getAllByText("Beta Model")).not.toHaveLength(0); + }); + + it("navigates to the detail route when a row is clicked", () => { + renderTable(); + const row = screen.getByText("Alpha Model").closest("tr")!; + fireEvent.click(row); + expect(mockNavigate).toHaveBeenCalledWith("/base-models/1"); + }); +}); diff --git a/frontend/src/features/base-models/utils/__tests__/common.test.ts b/frontend/src/features/base-models/utils/__tests__/common.test.ts new file mode 100644 index 000000000..fbbe4ab78 --- /dev/null +++ b/frontend/src/features/base-models/utils/__tests__/common.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { DATE_SORT_OPTIONS } from "@/features/base-models/utils/common"; + +describe("DATE_SORT_OPTIONS", () => { + it("has exactly two options", () => { + expect(DATE_SORT_OPTIONS).toHaveLength(2); + }); + + it("first option represents 'newest'", () => { + expect(DATE_SORT_OPTIONS[0]).toEqual({ label: "Newest", value: "newest" }); + }); + + it("second option represents 'oldest'", () => { + expect(DATE_SORT_OPTIONS[1]).toEqual({ label: "Oldest", value: "oldest" }); + }); +}); diff --git a/frontend/src/features/base-models/utils/__tests__/stac.test.ts b/frontend/src/features/base-models/utils/__tests__/stac.test.ts new file mode 100644 index 000000000..ea2ccc5bd --- /dev/null +++ b/frontend/src/features/base-models/utils/__tests__/stac.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect } from "vitest"; +import { + mapStacItemToBaseModel, + mapStacItemToBaseModelDetail, +} from "@/features/base-models/utils/stac"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeFeature = (overrides: Record = {}) => ({ + id: "model-abc", + bbox: [-180, -85, 180, 85], + assets: {}, + properties: { + title: "Test Model", + description: "A description", + updated: "2024-01-15T00:00:00Z", + version: "2", + keywords: ["building-detection", "segmentation"], + providers: [ + { name: "HOT", roles: ["producer"] }, + { name: "Other", roles: ["host"] }, + ], + ...overrides, + }, +}); + +// --------------------------------------------------------------------------- +// mapStacItemToBaseModel +// --------------------------------------------------------------------------- + +describe("mapStacItemToBaseModel", () => { + it("maps title from props.title", () => { + const model = mapStacItemToBaseModel(makeFeature()); + expect(model.name).toBe("Test Model"); + }); + + it("falls back to mlm:name when title is absent", () => { + const feature = makeFeature({ title: undefined, "mlm:name": "MLM Name" }); + const model = mapStacItemToBaseModel(feature); + expect(model.name).toBe("MLM Name"); + }); + + it("picks the producer provider for author", () => { + const model = mapStacItemToBaseModel(makeFeature()); + expect(model.author).toBe("HOT"); + }); + + it("falls back to the first provider when none has the producer role", () => { + const feature = makeFeature({ + providers: [{ name: "FallbackOrg", roles: ["host"] }], + }); + const model = mapStacItemToBaseModel(feature); + expect(model.author).toBe("FallbackOrg"); + }); + + it("returns 'Unknown' when providers is absent", () => { + const feature = makeFeature({ providers: undefined }); + const model = mapStacItemToBaseModel(feature); + expect(model.author).toBe("Unknown"); + }); + + it("picks the first keyword as task", () => { + const model = mapStacItemToBaseModel(makeFeature()); + expect(model.task).toBe("building-detection"); + }); + + it("defaults task to 'unknown' when keywords is empty", () => { + const feature = makeFeature({ keywords: [] }); + const model = mapStacItemToBaseModel(feature); + expect(model.task).toBe("unknown"); + }); + + it("defaults version to '1' when absent", () => { + const feature = makeFeature({ version: undefined }); + const model = mapStacItemToBaseModel(feature); + expect(model.version).toBe("1"); + }); + + it("defaults description to empty string when absent", () => { + const feature = makeFeature({ description: undefined }); + const model = mapStacItemToBaseModel(feature); + expect(model.description).toBe(""); + }); + + it("includes all keywords in model.keywords", () => { + const model = mapStacItemToBaseModel(makeFeature()); + expect(model.keywords).toEqual(["building-detection", "segmentation"]); + }); +}); + +// --------------------------------------------------------------------------- +// mapStacItemToBaseModelDetail +// --------------------------------------------------------------------------- + +const makeStacItem = (overrides: Record = {}, assetOverrides: Record = {}) => ({ + id: "detail-model-1", + bbox: [-10, -5, 10, 5] as [number, number, number, number], + assets: { + readme: { href: "https://example.com/readme.md", type: "text/markdown", title: "README", roles: ["overview"] }, + ...assetOverrides, + }, + properties: { + title: "Detail Model", + description: "Detailed description", + created: "2023-06-01T00:00:00Z", + updated: "2024-01-15T00:00:00Z", + version: "3", + datetime: "2023-01-01T00:00:00Z", + license: "ODbL", + keywords: ["segmentation"], + providers: [{ name: "HOT", roles: ["producer"] }], + "mlm:name": "ramp", + "mlm:architecture": "ResNet", + "mlm:framework": "TensorFlow", + "mlm:tasks": ["segmentation", "detection"], + "mlm:input": [], + "mlm:output": [], + ...overrides, + }, +}); + +describe("mapStacItemToBaseModelDetail", () => { + it("maps id and fullTitle", () => { + const model = mapStacItemToBaseModelDetail(makeStacItem()); + expect(model.id).toBe("detail-model-1"); + expect(model.fullTitle).toBe("Detail Model"); + }); + + it("maps bbox from item", () => { + const model = mapStacItemToBaseModelDetail(makeStacItem()); + expect(model.bbox).toEqual([-10, -5, 10, 5]); + }); + + it("sets bbox to null when absent", () => { + const item = { ...makeStacItem(), bbox: undefined }; + const model = mapStacItemToBaseModelDetail(item); + expect(model.bbox).toBeNull(); + }); + + it("maps markdownContent and readmeUrl from readme asset", () => { + const model = mapStacItemToBaseModelDetail(makeStacItem()); + expect(model.markdownContent).toBe("https://example.com/readme.md"); + expect(model.readmeUrl).toBe("https://example.com/readme.md"); + }); + + it("sets markdownContent to undefined when readme asset is absent", () => { + const item = makeStacItem({}, {}); + delete (item as any).assets.readme; + const model = mapStacItemToBaseModelDetail(item); + expect(model.markdownContent).toBeUndefined(); + }); + + it("maps mlmTasks correctly", () => { + const model = mapStacItemToBaseModelDetail(makeStacItem()); + expect(model.mlmTasks).toEqual(["segmentation", "detection"]); + }); + + it("maps mlmInput and mlmOutput as empty arrays when absent", () => { + const model = mapStacItemToBaseModelDetail(makeStacItem()); + expect(model.mlmInput).toEqual([]); + expect(model.mlmOutput).toEqual([]); + }); + + it("maps architecture fields from STAC properties", () => { + const model = mapStacItemToBaseModelDetail(makeStacItem()); + expect(model.architecture.baseModel).toBe("ramp"); + expect(model.architecture.architecture).toBe("ResNet"); + expect(model.architecture.framework).toBe("TensorFlow"); + }); + + it("maps assets array from item assets", () => { + const model = mapStacItemToBaseModelDetail(makeStacItem()); + expect(model.assets).toContainEqual( + expect.objectContaining({ key: "readme", href: "https://example.com/readme.md" }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// extractAccuracy (tested indirectly via mapStacItemToBaseModel) +// --------------------------------------------------------------------------- + +describe("accuracy extraction via mapStacItemToBaseModel", () => { + it("returns 0 when fair:metrics_spec is absent", () => { + const model = mapStacItemToBaseModel(makeFeature()); + expect(model.accuracy).toBe(0); + }); + + it("returns 0 when metrics_spec is empty", () => { + const feature = makeFeature({ "fair:metrics_spec": [] }); + const model = mapStacItemToBaseModel(feature); + expect(model.accuracy).toBe(0); + }); + + it("returns 0 when fair:accuracy metric is not present", () => { + const feature = makeFeature({ + "fair:metrics_spec": [{ key: "fair:precision", value: 0.9 }], + }); + const model = mapStacItemToBaseModel(feature); + expect(model.accuracy).toBe(0); + }); + + it("returns the metric value when fair:accuracy is present", () => { + const feature = makeFeature({ + "fair:metrics_spec": [{ key: "fair:accuracy", value: 0.87 }], + }); + const model = mapStacItemToBaseModel(feature); + expect(model.accuracy).toBe(0.87); + }); +}); diff --git a/frontend/src/features/base-models/utils/stac.ts b/frontend/src/features/base-models/utils/stac.ts index 969a3c854..08b9e9a90 100644 --- a/frontend/src/features/base-models/utils/stac.ts +++ b/frontend/src/features/base-models/utils/stac.ts @@ -88,10 +88,10 @@ export const mapStacItemToBaseModelDetail = (item: any) => { : undefined, frameworkVersion: p["mlm:framework_version"], pretrainedSource: p["mlm:pretrained_source"], - tileSizePx: "640", - processing: "preprocess pipeline", - resize: "640x640", - scaling: "0–1 normalization", + tileSizePx: p["fair:tile_size_px"] != null ? String(p["fair:tile_size_px"]) : undefined, + processing: p["fair:processing"] ?? undefined, + resize: p["fair:resize"] ?? undefined, + scaling: p["fair:scaling"] ?? undefined, outputDescription: p.description, variants: [], }, @@ -143,5 +143,5 @@ const extractAccuracy = (properties: any): number => { return 0; } - return 0; + return accuracyMetric.value ?? 0; }; From d753bebf7000a00ec94e8458dea00daa9c149898 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Sun, 28 Jun 2026 22:30:53 +0100 Subject: [PATCH 10/21] chore: clean up base models --- .../__tests__/base-model-keywords.test.tsx | 5 ++++- .../__tests__/base-models-list.test.tsx | 14 ++++++++++++-- .../base-models/layouts/__tests__/grid.test.tsx | 12 +++++++++--- .../base-models/utils/__tests__/stac.test.ts | 17 ++++++++++++++--- frontend/src/features/base-models/utils/stac.ts | 5 ++++- 5 files changed, 43 insertions(+), 10 deletions(-) diff --git a/frontend/src/features/base-models/components/__tests__/base-model-keywords.test.tsx b/frontend/src/features/base-models/components/__tests__/base-model-keywords.test.tsx index 98c9b28db..0d7790b68 100644 --- a/frontend/src/features/base-models/components/__tests__/base-model-keywords.test.tsx +++ b/frontend/src/features/base-models/components/__tests__/base-model-keywords.test.tsx @@ -34,7 +34,10 @@ describe("BaseModelKeywords", () => { it("does not render keywords beyond the visible limit", () => { render( - , + , ); expect(screen.queryByText("hidden")).not.toBeInTheDocument(); }); diff --git a/frontend/src/features/base-models/components/__tests__/base-models-list.test.tsx b/frontend/src/features/base-models/components/__tests__/base-models-list.test.tsx index 7e887571b..060b6b51f 100644 --- a/frontend/src/features/base-models/components/__tests__/base-models-list.test.tsx +++ b/frontend/src/features/base-models/components/__tests__/base-models-list.test.tsx @@ -142,8 +142,18 @@ describe("BaseModelsPage — loading / error states", () => { describe("BaseModelsPage — search filtering", () => { const models = [ - makeModel({ id: 1, name: "RAMP Detector", description: "Detects buildings", author: "HOT" }), - makeModel({ id: 2, name: "YOLOv8 Segmentor", description: "Fast segmentation", author: "OpenAI" }), + makeModel({ + id: 1, + name: "RAMP Detector", + description: "Detects buildings", + author: "HOT", + }), + makeModel({ + id: 2, + name: "YOLOv8 Segmentor", + description: "Fast segmentation", + author: "OpenAI", + }), ]; beforeEach(() => { diff --git a/frontend/src/features/base-models/layouts/__tests__/grid.test.tsx b/frontend/src/features/base-models/layouts/__tests__/grid.test.tsx index e2d64cc82..50d9c91b0 100644 --- a/frontend/src/features/base-models/layouts/__tests__/grid.test.tsx +++ b/frontend/src/features/base-models/layouts/__tests__/grid.test.tsx @@ -31,9 +31,15 @@ describe("BaseModelGridLayout", () => { makeModel(3, "Gamma Model"), ]; renderGrid(models); - expect(screen.getByRole("heading", { name: "Alpha Model" })).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "Beta Model" })).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "Gamma Model" })).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Alpha Model" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Beta Model" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Gamma Model" }), + ).toBeInTheDocument(); }); it("renders nothing when models array is empty", () => { diff --git a/frontend/src/features/base-models/utils/__tests__/stac.test.ts b/frontend/src/features/base-models/utils/__tests__/stac.test.ts index ea2ccc5bd..a5bcda670 100644 --- a/frontend/src/features/base-models/utils/__tests__/stac.test.ts +++ b/frontend/src/features/base-models/utils/__tests__/stac.test.ts @@ -94,11 +94,19 @@ describe("mapStacItemToBaseModel", () => { // mapStacItemToBaseModelDetail // --------------------------------------------------------------------------- -const makeStacItem = (overrides: Record = {}, assetOverrides: Record = {}) => ({ +const makeStacItem = ( + overrides: Record = {}, + assetOverrides: Record = {}, +) => ({ id: "detail-model-1", bbox: [-10, -5, 10, 5] as [number, number, number, number], assets: { - readme: { href: "https://example.com/readme.md", type: "text/markdown", title: "README", roles: ["overview"] }, + readme: { + href: "https://example.com/readme.md", + type: "text/markdown", + title: "README", + roles: ["overview"], + }, ...assetOverrides, }, properties: { @@ -173,7 +181,10 @@ describe("mapStacItemToBaseModelDetail", () => { it("maps assets array from item assets", () => { const model = mapStacItemToBaseModelDetail(makeStacItem()); expect(model.assets).toContainEqual( - expect.objectContaining({ key: "readme", href: "https://example.com/readme.md" }), + expect.objectContaining({ + key: "readme", + href: "https://example.com/readme.md", + }), ); }); }); diff --git a/frontend/src/features/base-models/utils/stac.ts b/frontend/src/features/base-models/utils/stac.ts index 08b9e9a90..61f0115a0 100644 --- a/frontend/src/features/base-models/utils/stac.ts +++ b/frontend/src/features/base-models/utils/stac.ts @@ -88,7 +88,10 @@ export const mapStacItemToBaseModelDetail = (item: any) => { : undefined, frameworkVersion: p["mlm:framework_version"], pretrainedSource: p["mlm:pretrained_source"], - tileSizePx: p["fair:tile_size_px"] != null ? String(p["fair:tile_size_px"]) : undefined, + tileSizePx: + p["fair:tile_size_px"] != null + ? String(p["fair:tile_size_px"]) + : undefined, processing: p["fair:processing"] ?? undefined, resize: p["fair:resize"] ?? undefined, scaling: p["fair:scaling"] ?? undefined, From 74ffaa7e833db4273d616269c6805b66f314eba0 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Mon, 29 Jun 2026 08:20:05 +0100 Subject: [PATCH 11/21] fix: resolve sidebar not scrollable --- frontend/src/app/routes/base-models/base-model-detail.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1f7f33a31..a737b88f6 100644 --- a/frontend/src/app/routes/base-models/base-model-detail.tsx +++ b/frontend/src/app/routes/base-models/base-model-detail.tsx @@ -364,7 +364,7 @@ export const BaseModelDetailPage = () => { {/* Right Column - Architecture Info */} -
+

Coverage

{/* Right: map extent — justified to the end */} From 983b0947ccc1ca2d306f371d6f1b27ecb2e247de Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Mon, 29 Jun 2026 14:47:01 +0100 Subject: [PATCH 12/21] fix: implement corrections --- .../routes/base-models/base-models-list.tsx | 11 ++-- .../base-model-cta/base-model-cta.module.css | 13 +++- .../landing/base-model-cta/base-model-cta.tsx | 59 +++++++++++-------- frontend/src/constants/general.ts | 10 ++++ .../constants/ui-contents/shared-content.ts | 1 + .../components/base-model-keywords.tsx | 4 +- .../components/contribute-model-dialog.tsx | 2 + .../components/mobile-base-model-filters.tsx | 2 +- .../features/base-models/layouts/table.tsx | 24 +++++++- frontend/src/types/ui-contents.ts | 1 + frontend/src/utils/string-utils.ts | 18 ++++++ 11 files changed, 110 insertions(+), 35 deletions(-) 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 b87681d04..a67af2d76 100644 --- a/frontend/src/app/routes/base-models/base-models-list.tsx +++ b/frontend/src/app/routes/base-models/base-models-list.tsx @@ -19,10 +19,11 @@ import { import { useBaseModels } from "@/features/base-models/hooks/use-base-models"; import { TBaseModel } from "@/types"; import { DATE_SORT_OPTIONS } from "@/features/base-models/utils/common"; +import { formatKeyword } from "@/utils"; const DATE_MENU_ITEMS = DATE_SORT_OPTIONS.map((opt) => ({ value: opt.label, - apiValue: opt.value, + apiValue: opt.value, })); export const BaseModelsPage = () => { @@ -60,9 +61,9 @@ export const BaseModelsPage = () => { return [ { label: "All", value: "all" }, - ...list.map((t) => ({ - label: t, - value: t, + ...list.map((item) => ({ + label: formatKeyword(item), + value: item, })), ]; }, [models]); @@ -179,7 +180,7 @@ export const BaseModelsPage = () => {
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 index 644bf5819..95d2c830c 100644 --- 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 @@ -38,11 +38,20 @@ } .ctaButtonContainer { - max-width: 213px; - width: 70%; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--sl-spacing-small); padding-top: var(--sl-spacing-medium); } +@media (min-width: 768px) { + .ctaButtonContainer { + flex-direction: row; + align-items: center; + } +} + .container .cta h1 { font-size: var(--hot-fair-font-size-title-2); font-weight: var(--hot-fair-font-weight-semibold); 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 d6138871f..fbd67fcb7 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 @@ -3,36 +3,45 @@ 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 { SHARED_CONTENT, APPLICATION_ROUTES } from "@/constants"; import { ButtonVariant } from "@/enums"; +import { useDialog } from "@/hooks/use-dialog"; +import ContributeModelDialog from "@/features/base-models/components/contribute-model-dialog"; export const BaseModelCTA = () => { + const { isOpened, openDialog, closeDialog } = useDialog(); + return ( -
-
-
-

{SHARED_CONTENT.homepage.baseModelCTA.title}

-

{SHARED_CONTENT.homepage.baseModelCTA.description}

-
-
- - + + - +
+
+
+ {SHARED_CONTENT.homepage.baseModelCTA.title}
-
-
- {SHARED_CONTENT.homepage.baseModelCTA.title} -
-
+ + ); }; diff --git a/frontend/src/constants/general.ts b/frontend/src/constants/general.ts index af25deeae..28bbb3081 100644 --- a/frontend/src/constants/general.ts +++ b/frontend/src/constants/general.ts @@ -7,6 +7,16 @@ export const navLinks: TNavBarLinks = [ title: SHARED_CONTENT.navbar.routes.exploreModels, href: APPLICATION_ROUTES.MODELS, active: true, + children: [ + { + title: "Models", + href: APPLICATION_ROUTES.MODELS, + }, + { + title: "Explore Base Models", + href: APPLICATION_ROUTES.BASE_MODELS_HOME, + }, + ], }, { title: SHARED_CONTENT.navbar.routes.exploreDatasets, diff --git a/frontend/src/constants/ui-contents/shared-content.ts b/frontend/src/constants/ui-contents/shared-content.ts index c63d80812..9ed53ed99 100644 --- a/frontend/src/constants/ui-contents/shared-content.ts +++ b/frontend/src/constants/ui-contents/shared-content.ts @@ -141,6 +141,7 @@ export const SHARED_CONTENT: TSharedContent = { baseModelCTA: { title: "Contribute Your Base Model", + secondButtonTitle: "Explore base models", 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-model-keywords.tsx b/frontend/src/features/base-models/components/base-model-keywords.tsx index 81d8f8328..4f65bae6b 100644 --- a/frontend/src/features/base-models/components/base-model-keywords.tsx +++ b/frontend/src/features/base-models/components/base-model-keywords.tsx @@ -1,3 +1,5 @@ +import { formatKeyword } from "@/utils"; + type BaseModelKeywordsProps = { keywords: string[]; visibleLimit?: number; @@ -16,7 +18,7 @@ export const BaseModelKeywords = ({ key={keyword} className="rounded-lg w-fit h-fit bg-off-white px-2 py-1 text-body-4 text-dark capitalize" > - {keyword} + {formatKeyword(keyword)} ))} 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 d4ca41dc2..afe7d5177 100644 --- a/frontend/src/features/base-models/components/contribute-model-dialog.tsx +++ b/frontend/src/features/base-models/components/contribute-model-dialog.tsx @@ -171,6 +171,8 @@ const ContributeModelDialog: React.FC = ({ {contributeModelDialogContent.github.buttonLabel} + + diff --git a/frontend/src/features/base-models/components/mobile-base-model-filters.tsx b/frontend/src/features/base-models/components/mobile-base-model-filters.tsx index c7e4381da..62add9bd2 100644 --- a/frontend/src/features/base-models/components/mobile-base-model-filters.tsx +++ b/frontend/src/features/base-models/components/mobile-base-model-filters.tsx @@ -89,7 +89,7 @@ const MobileBaseModelFiltersDialog: React.FC< }} defaultSelectedItem={selectedCategoryLabel} triggerComponent={ -

+

{selectedCategoryLabel}

} diff --git a/frontend/src/features/base-models/layouts/table.tsx b/frontend/src/features/base-models/layouts/table.tsx index bac7b13dc..c6ce8a57d 100644 --- a/frontend/src/features/base-models/layouts/table.tsx +++ b/frontend/src/features/base-models/layouts/table.tsx @@ -2,7 +2,7 @@ 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 { truncateString } from "@/utils"; +import { formatKeyword, truncateString } from "@/utils"; import { useNavigate } from "react-router-dom"; import { useState } from "react"; import { TBaseModel } from "@/types"; @@ -24,6 +24,9 @@ const columnDefinitions: ColumnDef[] = [ { accessorKey: "task", header: "Task", + cell: ({ row }) => ( + {formatKeyword(row.getValue("task") ?? "")} + ), }, { accessorKey: "author", @@ -33,6 +36,25 @@ const columnDefinitions: ColumnDef[] = [ accessorKey: "version", header: "Version", }, + { + accessorKey: "keywords", + header: "Keywords", + cell: ({ row }) => { + const keywords: string[] = row.getValue("keywords") ?? []; + return ( +
+ {keywords.map((kw) => ( + + {formatKeyword(kw)} + + ))} +
+ ); + }, + }, // { // accessorKey: "accuracy", // header: ({ column }) => ( diff --git a/frontend/src/types/ui-contents.ts b/frontend/src/types/ui-contents.ts index 924058f69..cf88547ff 100644 --- a/frontend/src/types/ui-contents.ts +++ b/frontend/src/types/ui-contents.ts @@ -437,6 +437,7 @@ export type TSharedContent = { description: string; ctaButton: string; ctaLink: string; + secondButtonTitle: string }; }; baseModelsPage: { diff --git a/frontend/src/utils/string-utils.ts b/frontend/src/utils/string-utils.ts index 8599d6ef5..88462ccc2 100644 --- a/frontend/src/utils/string-utils.ts +++ b/frontend/src/utils/string-utils.ts @@ -18,6 +18,24 @@ export const truncateString = (string?: string, maxLength: number = 30) => { return string; }; +/** + * Formats a keyword string by replacing hyphens and underscores with spaces + * and capitalizing the first letter of each word (title case). + * + * @example + * formatKeyword("swimming_pool") // → "Swimming Pool" + * formatKeyword("semantic-segmentation") // → "Semantic Segmentation" + + * + * @param {string} keyword - The raw keyword string to format. + * @returns {string} The formatted, human-readable label. + */ +export const formatKeyword = (keyword: string): string => { + return keyword + .replace(/[-_]/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); +}; + export const extractTileJSONURL = (OAMTMSURL: string) => { // Before, when we hit this url https://tiles.openaerialmap.org/63b457ba3fb8c100063c55f0/0/63b457ba3fb8c100063c55f1/{z}/{x}/{y} (without the /{z}/{x}/{y}), // we get the TileJSON which is passed to Maplibre GL JS to render the aerial imagery, but with the recent OAM updates From 6c69b31049d74a2b33037ca5c9f6440eac01ae0a Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Mon, 29 Jun 2026 14:47:43 +0100 Subject: [PATCH 13/21] fix: implement corrections --- frontend/src/app/routes/base-models/base-models-list.tsx | 2 +- .../src/components/landing/base-model-cta/base-model-cta.tsx | 4 +++- frontend/src/constants/general.ts | 2 +- .../base-models/components/contribute-model-dialog.tsx | 2 -- frontend/src/features/base-models/layouts/table.tsx | 4 +--- frontend/src/types/ui-contents.ts | 2 +- 6 files changed, 7 insertions(+), 9 deletions(-) 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 a67af2d76..384a79846 100644 --- a/frontend/src/app/routes/base-models/base-models-list.tsx +++ b/frontend/src/app/routes/base-models/base-models-list.tsx @@ -23,7 +23,7 @@ import { formatKeyword } from "@/utils"; const DATE_MENU_ITEMS = DATE_SORT_OPTIONS.map((opt) => ({ value: opt.label, - apiValue: opt.value, + apiValue: opt.value, })); export const BaseModelsPage = () => { 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 fbd67fcb7..b6ec0fb89 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 @@ -27,7 +27,9 @@ export const BaseModelCTA = () => { title={SHARED_CONTENT.homepage.baseModelCTA.secondButtonTitle} nativeAnchor > - + - - diff --git a/frontend/src/features/base-models/layouts/table.tsx b/frontend/src/features/base-models/layouts/table.tsx index c6ce8a57d..3b5cf59a3 100644 --- a/frontend/src/features/base-models/layouts/table.tsx +++ b/frontend/src/features/base-models/layouts/table.tsx @@ -24,9 +24,7 @@ const columnDefinitions: ColumnDef[] = [ { accessorKey: "task", header: "Task", - cell: ({ row }) => ( - {formatKeyword(row.getValue("task") ?? "")} - ), + cell: ({ row }) => {formatKeyword(row.getValue("task") ?? "")}, }, { accessorKey: "author", diff --git a/frontend/src/types/ui-contents.ts b/frontend/src/types/ui-contents.ts index cf88547ff..58badc05b 100644 --- a/frontend/src/types/ui-contents.ts +++ b/frontend/src/types/ui-contents.ts @@ -437,7 +437,7 @@ export type TSharedContent = { description: string; ctaButton: string; ctaLink: string; - secondButtonTitle: string + secondButtonTitle: string; }; }; baseModelsPage: { From bd087cbc5b1827e6bb55be2df8f784fc5a93cd84 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Mon, 29 Jun 2026 14:56:05 +0100 Subject: [PATCH 14/21] fix: implement corrections --- .../src/components/landing/base-model-cta/base-model-cta.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b6ec0fb89..11bad30ed 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 @@ -31,7 +31,7 @@ export const BaseModelCTA = () => { {SHARED_CONTENT.homepage.baseModelCTA.secondButtonTitle} - From 6e986644a1742032f8c5757d4571f231a9b64a0c Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Mon, 29 Jun 2026 14:59:21 +0100 Subject: [PATCH 15/21] fix: add max to contribute button --- .../components/landing/base-model-cta/base-model-cta.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 11bad30ed..c1dbefea6 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 @@ -31,7 +31,11 @@ export const BaseModelCTA = () => { {SHARED_CONTENT.homepage.baseModelCTA.secondButtonTitle} - From cacbfe4ad203c793ffaacaff9119c73fbaabbbb6 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 30 Jun 2026 09:42:00 +0100 Subject: [PATCH 16/21] chore: updated base model text --- frontend/src/constants/general.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/constants/general.ts b/frontend/src/constants/general.ts index 25756626d..f5df37ab1 100644 --- a/frontend/src/constants/general.ts +++ b/frontend/src/constants/general.ts @@ -13,7 +13,7 @@ export const navLinks: TNavBarLinks = [ href: APPLICATION_ROUTES.MODELS, }, { - title: "Explore Base Models", + title: "Base Models", href: APPLICATION_ROUTES.BASE_MODELS_HOME, }, ], From 53062b2a5465d802482c358f328c883dcd75a8b8 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 30 Jun 2026 09:47:00 +0100 Subject: [PATCH 17/21] updated button colors --- frontend/src/app/routes/base-models/base-model-detail.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a737b88f6..797af4a88 100644 --- a/frontend/src/app/routes/base-models/base-model-detail.tsx +++ b/frontend/src/app/routes/base-models/base-model-detail.tsx @@ -303,7 +303,7 @@ export const BaseModelDetailPage = () => { navigate(`${APPLICATION_ROUTES.START_MAPPING_BASE}${model.id}`) } prefixIcon={MapIcon} - variant={ButtonVariant.PRIMARY} + variant={ButtonVariant.TERTIARY} label="Start Mapping" /> From 55e0b95e0c6387c6f3e043c55454236264b64d58 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Tue, 30 Jun 2026 10:02:21 +0100 Subject: [PATCH 18/21] chore: updated button texts --- frontend/src/app/routes/base-models/base-model-detail.tsx | 2 +- frontend/src/app/routes/base-models/base-models-list.tsx | 2 +- frontend/src/constants/general.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 797af4a88..f9432a1e3 100644 --- a/frontend/src/app/routes/base-models/base-model-detail.tsx +++ b/frontend/src/app/routes/base-models/base-model-detail.tsx @@ -304,7 +304,7 @@ export const BaseModelDetailPage = () => { } prefixIcon={MapIcon} variant={ButtonVariant.TERTIARY} - label="Start Mapping" + label="Map with Base Model" /> 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 384a79846..9687e17f3 100644 --- a/frontend/src/app/routes/base-models/base-models-list.tsx +++ b/frontend/src/app/routes/base-models/base-models-list.tsx @@ -180,7 +180,7 @@ export const BaseModelsPage = () => {
diff --git a/frontend/src/constants/general.ts b/frontend/src/constants/general.ts index f5df37ab1..677c4cef0 100644 --- a/frontend/src/constants/general.ts +++ b/frontend/src/constants/general.ts @@ -9,7 +9,7 @@ export const navLinks: TNavBarLinks = [ active: true, children: [ { - title: "Models", + title: "AI Models", href: APPLICATION_ROUTES.MODELS, }, { From 00448da682d96cbb0deb234132571804a549ecff Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Wed, 1 Jul 2026 09:16:23 +0100 Subject: [PATCH 19/21] chore: fix mobile --- frontend/src/app/routes/base-models/base-model-detail.tsx | 5 ++++- .../src/components/landing/base-model-cta/base-model-cta.tsx | 4 ++-- frontend/src/constants/ui-contents/shared-content.ts | 2 +- .../base-models/components/contribute-model-dialog.tsx | 4 ++-- 4 files changed, 9 insertions(+), 6 deletions(-) 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 f9432a1e3..508039982 100644 --- a/frontend/src/app/routes/base-models/base-model-detail.tsx +++ b/frontend/src/app/routes/base-models/base-model-detail.tsx @@ -361,10 +361,13 @@ export const BaseModelDetailPage = () => { {/* Main Content: Two Column Layout */}
{/* Left Column - Overview */} +
+ +
{/* Right Column - Architecture Info */} -
+

Coverage

{/* Right: map extent — justified to the end */} 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 c1dbefea6..501460b9f 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 @@ -27,12 +27,12 @@ export const BaseModelCTA = () => { title={SHARED_CONTENT.homepage.baseModelCTA.secondButtonTitle} nativeAnchor > - From 840f59985425a5157864d84400a36857e1d5b108 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Wed, 1 Jul 2026 09:19:21 +0100 Subject: [PATCH 20/21] chore: rearranged detail items for mobile --- frontend/src/app/routes/base-models/base-model-detail.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 508039982..6a9d5356c 100644 --- a/frontend/src/app/routes/base-models/base-model-detail.tsx +++ b/frontend/src/app/routes/base-models/base-model-detail.tsx @@ -361,10 +361,9 @@ export const BaseModelDetailPage = () => { {/* Main Content: Two Column Layout */}
{/* Left Column - Overview */} -
- - -
+
+ +
{/* Right Column - Architecture Info */}
From 6e068863f62c32339e3af1f75a54b8b812394367 Mon Sep 17 00:00:00 2001 From: shittu adewale Date: Wed, 1 Jul 2026 10:01:28 +0100 Subject: [PATCH 21/21] chore: changed from px to rem --- .../src/components/landing/base-model-cta/base-model-cta.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 501460b9f..98820164f 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 @@ -27,12 +27,12 @@ export const BaseModelCTA = () => { title={SHARED_CONTENT.homepage.baseModelCTA.secondButtonTitle} nativeAnchor > -