diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 042ac7e7..b4362516 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 00000000..6a9d5356 --- /dev/null +++ b/frontend/src/app/routes/base-models/base-model-detail.tsx @@ -0,0 +1,439 @@ +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 { 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 !== "") + : []; + + return ( + <> + + + +
+ {/* Title + Start Mapping */} +
+
+

+ {model.fullTitle} +

+

Model ID: {model.dataId}

+
+
+ + navigate(`${APPLICATION_ROUTES.START_MAPPING_BASE}${model.id}`) + } + prefixIcon={MapIcon} + variant={ButtonVariant.TERTIARY} + label="Map with Base Model" + /> +
+
+ + {/* Metadata + Map Extent β€” metadata on left, map on right */} +
+ + + + + + + + + +
+ Tasks: + +
+ +
+ + {/* Download Metadata Link */} + {model.readmeUrl && ( +
+ + Download Metadata + + +
+ )} + + {/* Main Content: Two Column Layout */} +
+ {/* Left Column - Overview */} +
+ +
+ + {/* Right Column - Architecture Info */} +
+
+

Coverage

+ {/* Right: map extent β€” justified to the end */} + {model.bbox ? : null} +
+
+ +
+ {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 00000000..9687e17f --- /dev/null +++ b/frontend/src/app/routes/base-models/base-models-list.tsx @@ -0,0 +1,214 @@ +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"; +import { formatKeyword } from "@/utils"; + +const DATE_MENU_ITEMS = DATE_SORT_OPTIONS.map((opt) => ({ + value: opt.label, + apiValue: opt.value, +})); + +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((item) => ({ + label: formatKeyword(item), + value: item, + })), + ]; + }, [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.lastModified).getTime(); + const bDate = new Date(b.lastModified).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_MENU_ITEMS; + + 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 1e1de3a6..b592620d 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 00000000..29502cd5 Binary files /dev/null and b/frontend/src/assets/images/base_model_cta_image.png differ diff --git a/frontend/src/assets/images/index.ts b/frontend/src/assets/images/index.ts index 75655b22..93e19f1a 100644 --- a/frontend/src/assets/images/index.ts +++ b/frontend/src/assets/images/index.ts @@ -16,3 +16,4 @@ 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 00000000..95d2c830 --- /dev/null +++ b/frontend/src/components/landing/base-model-cta/base-model-cta.module.css @@ -0,0 +1,156 @@ +.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 { + 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); + 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 00000000..98820164 --- /dev/null +++ b/frontend/src/components/landing/base-model-cta/base-model-cta.tsx @@ -0,0 +1,53 @@ +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, 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} +
+
+ + ); +}; diff --git a/frontend/src/components/shared/markdown-render.tsx b/frontend/src/components/shared/markdown-render.tsx new file mode 100644 index 00000000..3a3c56bc --- /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/general.ts b/frontend/src/constants/general.ts index af25deea..677c4cef 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: "AI Models", + href: APPLICATION_ROUTES.MODELS, + }, + { + title: "Base Models", + href: APPLICATION_ROUTES.BASE_MODELS_HOME, + }, + ], }, { title: SHARED_CONTENT.navbar.routes.exploreDatasets, diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 222e7b6d..f2ad94ad 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -67,6 +67,11 @@ 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 a791a4bd..dd553130 100644 --- a/frontend/src/constants/ui-contents/shared-content.ts +++ b/frontend/src/constants/ui-contents/shared-content.ts @@ -138,7 +138,17 @@ 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", + 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", + ctaLink: "/base-models", + }, }, + pageNotFound: { messages: { constant: "Oh sorry,", @@ -152,6 +162,107 @@ export const SHARED_CONTENT: TSharedContent = { pageNotFound: "go to homepage", }, }, + baseModelsPage: { + pageHeadingTitle: "Base Models", + pageHeadingDescription: + " Each model is trained using one of the training datasets. Published models can be used to find mappable features in imagery that is similar to the training areas that dataset comes from.", + pageHeadingButtonText: "Contribute model", + contributeModelDialog: { + label: "Model Contribution Journey", + intro: + "Model contribution into fAIr is handled in GITHUB /fAIr-models repository. Here are high level explanation for the contribution four steps and detailed documentation is available when you go to GITHUB", + github: { + title: "Fair Model github", + href: "https://github.com/hotosm/fAIr-models", + buttonLabel: "Go to Github", + }, + steps: [ + { + title: "Complete Prerequisites", + description: + "Before opening a Pull Request, verify your model meets the technical and legal standards.", + sections: [ + { + title: "Define Licenses", + description: + "AI models require three distinct licenses. You must select one for each category:", + listType: "unordered", + items: [ + "Code License: (e.g., Apache 2.0, MIT, or GPLv3)", + "Weights License: (e.g., Apache 2.0, CC BY 4.0, or Custom)", + "Data License: (e.g., CC BY, CC BY-NC, or Custom Terms)", + ], + note: "Note: This will be automatically validated if your selections are HOT-compliant to prevent future rejection.", + }, + { + title: "Verify Model Endpoints", + description: + "Ensure your model code includes the four mandatory API endpoints:", + listType: "ordered", + items: [ + "Training: For model fine-tuning.", + "Inference: For generating predictions.", + "Preprocessing: For imagery preparation.", + "Postprocessing: For cleaning and formatting results.", + ], + }, + { + title: "Define Input/Output Shape", + description: + "Clearly describe the data formats your model handles.", + listType: "unordered", + items: [ + "Input Example: Image RGB (tiles) + GeoJSON (labels)", + "Output Example: GeoJSON (detections) or Mask raster (segmentation)", + ], + }, + { + title: "Select Task Category", + description: "Choose one of the currently supported tasks:", + listType: "unordered", + items: [ + "Semantic Segmentation", + "Instance Segmentation", + "Object Detection (Selected for this session)", + ], + }, + ], + }, + { + title: "Review Guidelines", + description: + "To align with our community standards, you must read and acknowledge the contribution rules.", + }, + { + title: "Submit and Track PR", + description: + "After reviewing the guidelines and finished the prerequisites, you can now open a PR.", + }, + { + title: "Approval & Deployment", + description: + "Your contribution enters the final review stage by the fAIr maintainers.", + statuses: [ + { + variant: "pending", + label: "🟑 Pending", + description: "Under review by maintainers or CI is running.", + }, + { + variant: "changes", + label: "πŸ”΄ Needs Changes", + description: "Feedback has been provided; updates are required.", + }, + { + variant: "approved", + label: "🟒 Approved", + description: "PR is merged! Your model is now a fAIr base model.", + }, + ], + }, + ], + }, + }, protectedPage: { ctaButton: "login", messageParagraph: "To access this page you have to login.", diff --git a/frontend/src/features/base-models/api/get-base-models.ts b/frontend/src/features/base-models/api/get-base-models.ts new file mode 100644 index 00000000..13756960 --- /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/__tests__/base-model-card.test.tsx b/frontend/src/features/base-models/components/__tests__/base-model-card.test.tsx new file mode 100644 index 00000000..0634f87b --- /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 00000000..0d7790b6 --- /dev/null +++ b/frontend/src/features/base-models/components/__tests__/base-model-keywords.test.tsx @@ -0,0 +1,53 @@ +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 00000000..060b6b51 --- /dev/null +++ b/frontend/src/features/base-models/components/__tests__/base-models-list.test.tsx @@ -0,0 +1,264 @@ +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-card.tsx b/frontend/src/features/base-models/components/base-model-card.tsx new file mode 100644 index 00000000..944974dc --- /dev/null +++ b/frontend/src/features/base-models/components/base-model-card.tsx @@ -0,0 +1,42 @@ +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 00000000..0c629486 --- /dev/null +++ b/frontend/src/features/base-models/components/base-model-detail-skeleton.tsx @@ -0,0 +1,84 @@ +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 00000000..4f65bae6 --- /dev/null +++ b/frontend/src/features/base-models/components/base-model-keywords.tsx @@ -0,0 +1,26 @@ +import { formatKeyword } from "@/utils"; + +type BaseModelKeywordsProps = { + keywords: string[]; + visibleLimit?: number; +}; + +export const BaseModelKeywords = ({ + keywords, + visibleLimit = 3, +}: BaseModelKeywordsProps) => { + const visible = keywords.slice(0, visibleLimit); + + return ( +
+ {visible.map((keyword) => ( + + {formatKeyword(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 00000000..360e957b --- /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 00000000..b3abe8a1 --- /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 00000000..e1127b5d --- /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: 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", +}; +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 00000000..a864d263 --- /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 00000000..62add9bd --- /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 00000000..63db5fe6 --- /dev/null +++ b/frontend/src/features/base-models/components/model-extent-map.tsx @@ -0,0 +1,92 @@ +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.setRenderWorldCopies(true); // repeat basemap + extent horizontally + map.fitBounds(bounds, { animate: false, padding: 32 }); + + 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 ( +
+ +
+ ); +}; 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 00000000..c4733cdb --- /dev/null +++ b/frontend/src/features/base-models/hooks/use-base-models.ts @@ -0,0 +1,46 @@ +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/__tests__/grid.test.tsx b/frontend/src/features/base-models/layouts/__tests__/grid.test.tsx new file mode 100644 index 00000000..50d9c91b --- /dev/null +++ b/frontend/src/features/base-models/layouts/__tests__/grid.test.tsx @@ -0,0 +1,50 @@ +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 00000000..05cea9b1 --- /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/layouts/grid.tsx b/frontend/src/features/base-models/layouts/grid.tsx new file mode 100644 index 00000000..955e400b --- /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 00000000..058382dd --- /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 00000000..3b5cf59a --- /dev/null +++ b/frontend/src/features/base-models/layouts/table.tsx @@ -0,0 +1,98 @@ +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 { formatKeyword, 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", + cell: ({ row }) => {formatKeyword(row.getValue("task") ?? "")}, + }, + { + accessorKey: "author", + header: "Created by", + }, + { + 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 }) => ( + // + // ), + // 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/__tests__/common.test.ts b/frontend/src/features/base-models/utils/__tests__/common.test.ts new file mode 100644 index 00000000..fbbe4ab7 --- /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 00000000..a5bcda67 --- /dev/null +++ b/frontend/src/features/base-models/utils/__tests__/stac.test.ts @@ -0,0 +1,223 @@ +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/common.ts b/frontend/src/features/base-models/utils/common.ts new file mode 100644 index 00000000..7d301355 --- /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 00000000..61f0115a --- /dev/null +++ b/frontend/src/features/base-models/utils/stac.ts @@ -0,0 +1,150 @@ +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: + 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: [], + }, + + 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 accuracyMetric.value ?? 0; +}; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index f9b68a3f..53252d5e 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 06a4b5ee..58badc05 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; + secondButtonTitle: 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: { diff --git a/frontend/src/utils/string-utils.ts b/frontend/src/utils/string-utils.ts index 8599d6ef..88462ccc 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