From b2703e27c144f7c1b63db6a2f8deb217c81da315 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 25 Jun 2025 11:54:44 +0200 Subject: [PATCH 01/11] style: tweak for print --- ui/src/app.module.scss | 10 ++++++++-- ui/src/app.tsx | 2 +- ui/src/components/filtering/filter-section.tsx | 2 +- .../components/dialog/dialog.module.scss | 14 ++++++++++++-- .../components/page-footer/page-footer.tsx | 3 ++- .../components/page-header/page-header.tsx | 2 +- .../components/table/table/table.tsx | 10 +++++++--- ui/src/design-system/variables/typography.scss | 17 ++++++++++------- ui/src/index.css | 6 ++++++ 9 files changed, 48 insertions(+), 18 deletions(-) diff --git a/ui/src/app.module.scss b/ui/src/app.module.scss index 058e34594..76b6001ae 100644 --- a/ui/src/app.module.scss +++ b/ui/src/app.module.scss @@ -28,14 +28,20 @@ position: relative; } -@media only screen and (max-width: $medium-screen-breakpoint) { +@media (max-width: $medium-screen-breakpoint) { .main { padding: 24px 24px 96px; } } -@media only screen and (max-width: $small-screen-breakpoint) { +@media (max-width: $small-screen-breakpoint) { .main { padding: 16px 16px 80px; } } + +@media print { + .wrapper { + height: auto; + } +} diff --git a/ui/src/app.tsx b/ui/src/app.tsx index f849de879..f15d8b435 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -74,7 +74,7 @@ export const App = () => ( />
-
+
diff --git a/ui/src/components/filtering/filter-section.tsx b/ui/src/components/filtering/filter-section.tsx index 3f5ce2049..029f5a5ff 100644 --- a/ui/src/components/filtering/filter-section.tsx +++ b/ui/src/components/filtering/filter-section.tsx @@ -14,7 +14,7 @@ export const FilterSection = ({ defaultOpen, title = 'Filters', }: FilterSectionProps) => ( - + = BREAKPOINTS.MD ? defaultOpen : false} diff --git a/ui/src/design-system/components/dialog/dialog.module.scss b/ui/src/design-system/components/dialog/dialog.module.scss index 4faa6497a..4e937b759 100644 --- a/ui/src/design-system/components/dialog/dialog.module.scss +++ b/ui/src/design-system/components/dialog/dialog.module.scss @@ -108,14 +108,14 @@ $dialog-padding-medium: 32px; padding: 32px; } -@media only screen and (max-width: $medium-screen-breakpoint) { +@media (max-width: $medium-screen-breakpoint) { .dialog { max-width: calc(100% - (2 * $dialog-padding-medium)); max-height: calc(100vh - (2 * $dialog-padding-medium)); } } -@media only screen and (max-width: $small-screen-breakpoint) { +@media (max-width: $small-screen-breakpoint) { .dialog:not(.compact) { width: 100%; height: 100vh; @@ -136,3 +136,13 @@ $dialog-padding-medium: 32px; padding: 0 16px; } } + +@media print { + .dialogContent { + box-shadow: none; + } + + .dialogClose { + display: none; + } +} diff --git a/ui/src/design-system/components/page-footer/page-footer.tsx b/ui/src/design-system/components/page-footer/page-footer.tsx index 6be1ed743..2fba49099 100644 --- a/ui/src/design-system/components/page-footer/page-footer.tsx +++ b/ui/src/design-system/components/page-footer/page-footer.tsx @@ -1,3 +1,4 @@ +import classNames from 'classnames' import { ReactNode } from 'react' import styles from './page-footer.module.scss' @@ -12,7 +13,7 @@ export const PageFooter = ({ hide, children }: PageFooterProps) => { } return ( -
+
{children}
) diff --git a/ui/src/design-system/components/page-header/page-header.tsx b/ui/src/design-system/components/page-header/page-header.tsx index 1eeecc772..76eca0b40 100644 --- a/ui/src/design-system/components/page-header/page-header.tsx +++ b/ui/src/design-system/components/page-header/page-header.tsx @@ -43,6 +43,6 @@ export const PageHeader = ({
-
{children}
+
{children}
) diff --git a/ui/src/design-system/components/table/table/table.tsx b/ui/src/design-system/components/table/table/table.tsx index b243fe408..03bfa5f36 100644 --- a/ui/src/design-system/components/table/table/table.tsx +++ b/ui/src/design-system/components/table/table/table.tsx @@ -150,9 +150,13 @@ export const Table = ({
) diff --git a/ui/src/design-system/variables/typography.scss b/ui/src/design-system/variables/typography.scss index 90c324c61..1e3f94070 100644 --- a/ui/src/design-system/variables/typography.scss +++ b/ui/src/design-system/variables/typography.scss @@ -92,15 +92,18 @@ } @mixin bubble-label { - display: inline-flex; - height: auto; - border-radius: 4px; - padding: 6px 8px 4px; - background-color: $color-primary-1-50; - color: $color-primary-1-600; font-size: 16px; line-height: 22px; - font-weight: 600; + + @media not print { + display: inline-flex; + height: auto; + border-radius: 4px; + padding: 6px 8px 4px; + background-color: $color-primary-1-50; + color: $color-primary-1-600; + font-weight: 600; + } } @mixin mono { diff --git a/ui/src/index.css b/ui/src/index.css index 2cd6bef06..337b8b01a 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -35,3 +35,9 @@ a:focus-visible { @apply bg-background text-foreground; } } + +@media print { + .no-print { + display: none !important; + } +} From 3dda60e3def854e3b491a920d122a99413330484 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 25 Jun 2025 11:56:45 +0200 Subject: [PATCH 02/11] chore: update model classes to match backend updates --- .../data-services/models/species-details.ts | 36 ++-------- ui/src/data-services/models/species.ts | 71 ++++++++++++++----- ui/src/data-services/models/taxa.ts | 3 + 3 files changed, 64 insertions(+), 46 deletions(-) diff --git a/ui/src/data-services/models/species-details.ts b/ui/src/data-services/models/species-details.ts index da88c4f6f..f9da54598 100644 --- a/ui/src/data-services/models/species-details.ts +++ b/ui/src/data-services/models/species-details.ts @@ -1,45 +1,23 @@ -import _ from 'lodash' -import { getCompactTimespanString } from 'utils/date/getCompactTimespanString/getCompactTimespanString' import { ServerSpecies, Species } from './species' export type ServerSpeciesDetails = ServerSpecies & any // TODO: Update this type export class SpeciesDetails extends Species { - private readonly _occurrences: string[] = [] - public constructor(species: ServerSpeciesDetails) { super(species) - this._occurrences = this._species.occurrences.map((d: any) => `${d.id}`) - } - - get occurrences(): string[] { - return this._occurrences } - getOccurrenceInfo(id: string) { - const occurrence = this._species.occurrences.find( - (d: any) => `${d.id}` === id - ) + get exampleOccurrence() { + const occurrence = this._species.occurrences?.[0] - if (!occurrence || !occurrence.best_detection) { - return + if (!occurrence?.best_detection) { + return undefined } return { - id, - image: { - src: occurrence.best_detection.url, - width: occurrence.best_detection.width, - height: occurrence.best_detection.height, - }, - label: `${occurrence.event.name}\n ${ - occurrence.determination.name - } (${_.round(occurrence.determination_score, 4)})`, - timeLabel: getCompactTimespanString({ - date1: new Date(occurrence.first_appearance_timestamp), - date2: new Date(occurrence.last_appearance_timestamp), - }), - countLabel: `${occurrence.detections_count}`, + id: occurrence.id, + url: occurrence.best_detection.url, + caption: undefined, } } } diff --git a/ui/src/data-services/models/species.ts b/ui/src/data-services/models/species.ts index 011126d45..9e1461f93 100644 --- a/ui/src/data-services/models/species.ts +++ b/ui/src/data-services/models/species.ts @@ -1,47 +1,84 @@ +import { getFormatedDateTimeString } from 'utils/date/getFormatedDateTimeString/getFormatedDateTimeString' import { Taxon } from './taxa' export type ServerSpecies = any // TODO: Update this type export class Species extends Taxon { protected readonly _species: ServerSpecies - private readonly _images: { src: string }[] = [] public constructor(species: ServerSpecies) { super(species) this._species = species + } + + get coverImage(): { url: string; caption?: string } | undefined { + if (!this._species.cover_image_url) { + return undefined + } - if (species.occurrence_images?.length) { - this._images = species.occurrence_images.map((image: any) => ({ - src: image, - })) + return { + url: this._species.cover_image_url, + caption: this._species.cover_image_credit ?? undefined, } } - get images(): { src: string }[] { - return this._images + get createdAt(): string { + return getFormatedDateTimeString({ + date: new Date(this._species.created_at), + }) + } + + get lastSeenLabel() { + if (!this._species.last_detected) { + return undefined + } + + const date = new Date(this._species.last_detected) + + return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}` } get numDetections(): number { - return this._species.detections_count || null + return this._species.detections_count ?? 0 } get numOccurrences(): number { - return this._species.occurrences_count || null + return this._species.occurrences_count ?? 0 } - get trainingImagesLabel(): string { - return 'GBIF' + get gbifUrl(): string { + return `https://www.gbif.org/occurrence/gallery?advanced=1&verbatim_scientific_name=${this.name}` } - get trainingImagesUrl(): string { - return `https://www.gbif.org/occurrence/gallery?advanced=1&verbatim_scientific_name=${this.name}` + get fieldguideUrl(): string | undefined { + if (!this._species.fieldguide_id) { + return undefined + } + + return `https://leps.fieldguide.ai/categories?category=${this._species.fieldguide_id}` } - get score(): number { - return this._species.best_determination_score || 0 + get score(): number | undefined { + const score = this._species.best_determination_score + + if (score || score === 0) { + return score + } + + return undefined + } + + get scoreLabel(): string | undefined { + if (this.score !== undefined) { + return this.score.toFixed(2) + } + + return undefined } - get scoreLabel(): string { - return this.score.toFixed(2) + get updatedAt(): string { + return getFormatedDateTimeString({ + date: new Date(this._species.updated_at), + }) } } diff --git a/ui/src/data-services/models/taxa.ts b/ui/src/data-services/models/taxa.ts index a80737897..18d36810e 100644 --- a/ui/src/data-services/models/taxa.ts +++ b/ui/src/data-services/models/taxa.ts @@ -2,6 +2,7 @@ export type ServerTaxon = { id: string name: string rank: string + cover_image_url: string | null parent?: ServerTaxon parents?: ServerTaxon[] } @@ -25,12 +26,14 @@ export class Taxon { readonly parentId?: string readonly rank: string readonly ranks: { id: string; name: string; rank: string }[] + readonly image?: string public constructor(taxon: ServerTaxon) { this.id = `${taxon.id}` this.name = taxon.name this.parentId = taxon.parent ? `${taxon.parent?.id}` : undefined this.rank = taxon.rank + this.image = taxon.cover_image_url ?? undefined if (taxon.parents) { this.ranks = taxon.parents From 3efa833e611e630694d2032d4077a2f4291e1078 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 25 Jun 2025 11:57:04 +0200 Subject: [PATCH 03/11] chore: streamline score handling in model classes --- .../models/classification-details.ts | 9 ++++----- .../models/occurrence-details.ts | 6 ++---- ui/src/data-services/models/occurrence.ts | 19 ++++++++++++------- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/ui/src/data-services/models/classification-details.ts b/ui/src/data-services/models/classification-details.ts index 5c6dab253..3bd8e9e0d 100644 --- a/ui/src/data-services/models/classification-details.ts +++ b/ui/src/data-services/models/classification-details.ts @@ -1,4 +1,3 @@ -import _ from 'lodash' import { Algorithm } from './algorithm' import { Taxon } from './taxa' @@ -24,8 +23,8 @@ export class ClassificationDetails { .slice(0, 5) .filter(({ taxon }: any) => !!taxon) .map(({ logit, score, taxon }: any) => ({ - logit: _.round(logit, 4), - score: _.round(score, 4), + logit, + score, taxon: new Taxon(taxon), })) : [] @@ -36,10 +35,10 @@ export class ClassificationDetails { } get logit(): number { - return _.round(this._classification.logit, 4) + return this._classification.logit } get score(): number { - return _.round(this._classification.score, 4) + return this._classification.score } } diff --git a/ui/src/data-services/models/occurrence-details.ts b/ui/src/data-services/models/occurrence-details.ts index b34caed71..90654984f 100644 --- a/ui/src/data-services/models/occurrence-details.ts +++ b/ui/src/data-services/models/occurrence-details.ts @@ -1,4 +1,3 @@ -import _ from 'lodash' import { getFormatedTimeString } from 'utils/date/getFormatedTimeString/getFormatedTimeString' import { UserPermission } from 'utils/user/types' import { Algorithm } from './algorithm' @@ -131,9 +130,8 @@ export class OccurrenceDetails extends Occurrence { let label = 'No classification' if (classification) { - label = `${classification.taxon.name} (${_.round( - classification.score, - 4 + label = `${classification.taxon.name} (${classification.score.toFixed( + 2 )})` } diff --git a/ui/src/data-services/models/occurrence.ts b/ui/src/data-services/models/occurrence.ts index 5ba0d41aa..4d32b677c 100644 --- a/ui/src/data-services/models/occurrence.ts +++ b/ui/src/data-services/models/occurrence.ts @@ -1,4 +1,3 @@ -import _ from 'lodash' import { getFormatedDateString } from 'utils/date/getFormatedDateString/getFormatedDateString' import { getFormatedDateTimeString } from 'utils/date/getFormatedDateTimeString/getFormatedDateTimeString' import { getFormatedTimeString } from 'utils/date/getFormatedTimeString/getFormatedTimeString' @@ -68,18 +67,24 @@ export class Occurrence { return determinationPrediction ? `${determinationPrediction.id}` : undefined } - get determinationScore(): number { + get determinationScore(): number | undefined { const score = this._occurrence.determination_details.score - if (score === undefined) { - return 0 + if (score || score === 0) { + return score } - return _.round(this._occurrence.determination_score, 4) + if (!score) { + return undefined + } } - get determinationScoreLabel(): string { - return this.determinationScore.toFixed(2) + get determinationScoreLabel(): string | undefined { + if (this.determinationScore !== undefined) { + return this.determinationScore.toFixed(2) + } + + return undefined } get determinationTaxon(): Taxon { From 6c94f7f91f6646cff15b581a1880b1b59c679a75 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 25 Jun 2025 11:58:18 +0200 Subject: [PATCH 04/11] feat: add images to taxa search --- ui/package.json | 2 +- .../components/taxon-search/taxon-search.tsx | 18 +++++++++++++++--- ui/src/components/taxon-search/types.ts | 1 + .../components/taxon-search/useTaxonSearch.ts | 4 ++-- ui/yarn.lock | 10 +++++----- 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/ui/package.json b/ui/package.json index afe4b1741..1719f3e08 100644 --- a/ui/package.json +++ b/ui/package.json @@ -29,7 +29,7 @@ "leaflet": "^1.9.3", "lodash": "^4.17.21", "lucide-react": "^0.454.0", - "nova-ui-kit": "^1.1.30", + "nova-ui-kit": "^1.1.31", "plotly.js": "^2.25.2", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/ui/src/components/taxon-search/taxon-search.tsx b/ui/src/components/taxon-search/taxon-search.tsx index 0ecb8a0be..17b71542e 100644 --- a/ui/src/components/taxon-search/taxon-search.tsx +++ b/ui/src/components/taxon-search/taxon-search.tsx @@ -1,6 +1,7 @@ import { Taxon } from 'data-services/models/taxa' import { Command } from 'nova-ui-kit' import { useMemo, useState } from 'react' +import { useParams } from 'react-router-dom' import { useDebounce } from 'utils/useDebounce' import { buildTree } from './buildTree' import { TreeItem } from './types' @@ -13,9 +14,13 @@ export const TaxonSearch = ({ taxon?: Taxon onTaxonChange: (taxon?: Taxon) => void }) => { + const { projectId } = useParams() const [searchString, setSearchString] = useState('') const debouncedSearchString = useDebounce(searchString, 200) - const { data, isLoading } = useTaxonSearch(debouncedSearchString) + const { data, isLoading } = useTaxonSearch( + debouncedSearchString, + projectId as string + ) const tree = useMemo(() => { if (!data?.length) { @@ -28,12 +33,18 @@ export const TaxonSearch = ({ label: taxon.name, details: taxon.rank, parentId: taxon.parentId, + image: taxon.image, })) ) }, [data]) return ( - + void }) => ( <> - onSelect(treeItem)}> + onSelect(treeItem)}> 0} + image={treeItem.image} label={treeItem.label} level={level} rank={treeItem.details ?? 'Unknown'} diff --git a/ui/src/components/taxon-search/types.ts b/ui/src/components/taxon-search/types.ts index 0a08b66d6..77346f1a6 100644 --- a/ui/src/components/taxon-search/types.ts +++ b/ui/src/components/taxon-search/types.ts @@ -3,6 +3,7 @@ export type Node = { label: string details?: string parentId?: string + image?: string } export type TreeItem = Node & { children: TreeItem[] } diff --git a/ui/src/components/taxon-search/useTaxonSearch.ts b/ui/src/components/taxon-search/useTaxonSearch.ts index c1eb56c30..017757d52 100644 --- a/ui/src/components/taxon-search/useTaxonSearch.ts +++ b/ui/src/components/taxon-search/useTaxonSearch.ts @@ -22,12 +22,12 @@ const convertServerResults = (result: ServerTaxon[]): Taxon[] => { return _.unionWith(taxa, (t1, t2) => t1.id === t2.id) } -export const useTaxonSearch = (searchString: string) => { +export const useTaxonSearch = (searchString: string, projectId: string) => { const [data, setData] = useState() const [isLoading, setIsLoading] = useState() const [error, setError] = useState() const fetchUrl = searchString.length - ? `${API_URL}/taxa/suggest/?q=${searchString}&limit=${MAX_NUM_RESULTS}` + ? `${API_URL}/taxa/suggest/?q=${searchString}&limit=${MAX_NUM_RESULTS}&project_id=${projectId}` : undefined useEffect(() => { diff --git a/ui/yarn.lock b/ui/yarn.lock index 53c8e68ae..0c2157cef 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -4932,7 +4932,7 @@ __metadata: leaflet: "npm:^1.9.3" lodash: "npm:^4.17.21" lucide-react: "npm:^0.454.0" - nova-ui-kit: "npm:^1.1.30" + nova-ui-kit: "npm:^1.1.31" plotly.js: "npm:^2.25.2" postcss: "npm:^8.4.47" prettier: "npm:2.8.4" @@ -10309,9 +10309,9 @@ __metadata: languageName: node linkType: hard -"nova-ui-kit@npm:^1.1.30": - version: 1.1.30 - resolution: "nova-ui-kit@npm:1.1.30" +"nova-ui-kit@npm:^1.1.31": + version: 1.1.31 + resolution: "nova-ui-kit@npm:1.1.31" dependencies: "@radix-ui/react-checkbox": "npm:^1.1.4" "@radix-ui/react-collapsible": "npm:^1.1.1" @@ -10330,7 +10330,7 @@ __metadata: react-dom: "npm:^18.3.1" tailwind-merge: "npm:^2.5.4" tailwindcss-animate: "npm:^1.0.7" - checksum: e8b041d06ca4333abef7bfca7b13867e17ba55cc195ff9f5dace83fb9379cfa0b32e08135ce39637d09773950c9dd6657ffdf1f8d3b160469f31ed176d9ed4a4 + checksum: a9c150152359299d1b09436bfe0cbd525f317845922149eb53d8597196f8dd67d1079519b4d592cd6a522053122446f626ebe6de5fc9a964bda75d3d144416fc languageName: node linkType: hard From 696d228ef61e285efc10cd4d794462177cba8029 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 25 Jun 2025 12:00:14 +0200 Subject: [PATCH 05/11] feat: update species list view with more information, extended filtering and column settings --- ui/src/components/determination-score.tsx | 28 +++++++ .../components/filtering/filter-control.tsx | 5 +- .../filtering/filters/boolean-filter.tsx | 43 ++++++++++ .../filtering/filters/taxon-filter.tsx | 6 +- ui/src/components/gallery/gallery.module.scss | 3 +- .../components/card/card.module.scss | 2 +- .../image-carousel/image-carousel.module.scss | 12 +-- .../image-carousel/image-carousel.tsx | 10 +-- .../image-table-cell.module.scss | 2 +- .../pages/occurrences/occurrence-columns.tsx | 78 +++++------------ .../pages/occurrences/occurrence-gallery.tsx | 3 +- .../pages/occurrences/occurrences.module.scss | 15 ---- ui/src/pages/species/species-columns.tsx | 83 ++++++++++++------- ui/src/pages/species/species-gallery.tsx | 2 +- ui/src/pages/species/species.tsx | 29 ++++++- ui/src/utils/useFilters.ts | 11 ++- .../userPreferencesContext.tsx | 5 +- 17 files changed, 207 insertions(+), 130 deletions(-) create mode 100644 ui/src/components/determination-score.tsx create mode 100644 ui/src/components/filtering/filters/boolean-filter.tsx diff --git a/ui/src/components/determination-score.tsx b/ui/src/components/determination-score.tsx new file mode 100644 index 000000000..1267612aa --- /dev/null +++ b/ui/src/components/determination-score.tsx @@ -0,0 +1,28 @@ +import { BasicTooltip } from 'design-system/components/tooltip/basic-tooltip' +import { IdentificationScore } from 'nova-ui-kit' +import { STRING, translate } from 'utils/language' + +export const DeterminationScore = ({ + confirmed, + score, + scoreLabel, + tooltip, +}: { + confirmed?: boolean + score?: number + scoreLabel?: string + tooltip?: string +}) => { + if (score === undefined || scoreLabel === undefined) { + return {translate(STRING.VALUE_NOT_AVAILABLE)} + } + + return ( + +
+ + {scoreLabel} +
+
+ ) +} diff --git a/ui/src/components/filtering/filter-control.tsx b/ui/src/components/filtering/filter-control.tsx index b7d56f749..326eda4bf 100644 --- a/ui/src/components/filtering/filter-control.tsx +++ b/ui/src/components/filtering/filter-control.tsx @@ -2,6 +2,7 @@ import { X } from 'lucide-react' import { Button } from 'nova-ui-kit' import { useFilters } from 'utils/useFilters' import { AlgorithmFilter, NotAlgorithmFilter } from './filters/algorithm-filter' +import { BooleanFilter } from './filters/boolean-filter' import { CollectionFilter } from './filters/collection-filter' import { DateFilter } from './filters/date-filter' import { ImageFilter } from './filters/image-filter' @@ -21,6 +22,7 @@ const ComponentMap: { [key: string]: (props: FilterProps) => JSX.Element } = { algorithm: AlgorithmFilter, + best_determination_score: ScoreFilter, classification_threshold: ScoreFilter, collection: CollectionFilter, date_end: DateFilter, @@ -28,14 +30,15 @@ const ComponentMap: { deployment: StationFilter, detections__source_image: ImageFilter, event: SessionFilter, + include_unobserved: BooleanFilter, job_type_key: TypeFilter, not_algorithm: NotAlgorithmFilter, pipeline: PipelineFilter, source_image_collection: CollectionFilter, source_image_single: ImageFilter, status: StatusFilter, - taxon: TaxonFilter, taxa_list_id: TaxaListFilter, + taxon: TaxonFilter, verified_by_me: VerifiedByFilter, verified: VerificationStatusFilter, } diff --git a/ui/src/components/filtering/filters/boolean-filter.tsx b/ui/src/components/filtering/filters/boolean-filter.tsx new file mode 100644 index 000000000..eb39a65c3 --- /dev/null +++ b/ui/src/components/filtering/filters/boolean-filter.tsx @@ -0,0 +1,43 @@ +import { Select } from 'nova-ui-kit' +import { booleanToString, stringToBoolean } from '../utils' +import { FilterProps } from './types' + +const OPTIONS = [ + { value: true, label: 'Yes' }, + { value: false, label: 'No' }, +] + +export const BooleanFilter = ({ + value: string, + onAdd, + onClear, +}: FilterProps) => { + const value = stringToBoolean(string) ?? false + + return ( + { + if (stringToBoolean(value)) { + onAdd(value) + } else { + onClear() + } + }} + > + + + + + {OPTIONS.map((option) => ( + + {option.label} + + ))} + + + ) +} diff --git a/ui/src/components/filtering/filters/taxon-filter.tsx b/ui/src/components/filtering/filters/taxon-filter.tsx index 4a8927d11..a43bc1017 100644 --- a/ui/src/components/filtering/filters/taxon-filter.tsx +++ b/ui/src/components/filtering/filters/taxon-filter.tsx @@ -28,10 +28,12 @@ export const TaxonFilter = ({ value, onAdd, onClear }: FilterProps) => { variant="outline" role="combobox" aria-expanded={open} - className="w-full justify-between px-4 text-muted-foreground font-normal" + className="w-full justify-between px-4 text-muted-foreground font-normal overflow-hidden" > <> - {triggerLabel} + + {triggerLabel} + {isLoading && value ? ( ) : ( diff --git a/ui/src/components/gallery/gallery.module.scss b/ui/src/components/gallery/gallery.module.scss index f1aa2dec6..02ef9d179 100644 --- a/ui/src/components/gallery/gallery.module.scss +++ b/ui/src/components/gallery/gallery.module.scss @@ -8,7 +8,6 @@ grid-template-columns: 1fr 1fr 1fr 1fr 1fr; gap: 16px; width: 100%; - min-height: 320px; padding-top: 24px; &.loading { @@ -28,7 +27,7 @@ justify-content: center; } -@media only screen and (max-width: $large-screen-breakpoint) { +@media (max-width: $large-screen-breakpoint) { .gallery { grid-template-columns: 1fr 1fr 1fr !important; } diff --git a/ui/src/design-system/components/card/card.module.scss b/ui/src/design-system/components/card/card.module.scss index 072fcf953..40a0fc1e2 100644 --- a/ui/src/design-system/components/card/card.module.scss +++ b/ui/src/design-system/components/card/card.module.scss @@ -14,6 +14,7 @@ position: relative; background-color: $color-generic-white; border-bottom: 1px solid $color-neutral-100; + overflow: hidden; } .square::after { @@ -29,7 +30,6 @@ top: 50%; left: 50%; transform: translate(-50%, -50%); - padding: 16px; box-sizing: border-box; } diff --git a/ui/src/design-system/components/image-carousel/image-carousel.module.scss b/ui/src/design-system/components/image-carousel/image-carousel.module.scss index 3b2f98c99..ef6fd9bf6 100644 --- a/ui/src/design-system/components/image-carousel/image-carousel.module.scss +++ b/ui/src/design-system/components/image-carousel/image-carousel.module.scss @@ -18,17 +18,13 @@ border-radius: 8px; background-color: $color-neutral-50; border: 1px solid $color-neutral-100; + overflow: hidden; &.light { background-color: $color-generic-white; } } -.image { - max-width: 100%; - max-height: 100%; -} - /* Slideshow styles */ .row { @@ -52,10 +48,14 @@ display: flex; align-items: center; justify-content: center; - padding: 10px; box-sizing: border-box; overflow: hidden; + img { + max-width: 100%; + max-height: 100%; + } + &:not(.visible) { opacity: 0; transition-delay: 0; diff --git a/ui/src/design-system/components/image-carousel/image-carousel.tsx b/ui/src/design-system/components/image-carousel/image-carousel.tsx index fa13af891..1b7c8bd56 100644 --- a/ui/src/design-system/components/image-carousel/image-carousel.tsx +++ b/ui/src/design-system/components/image-carousel/image-carousel.tsx @@ -1,4 +1,5 @@ import classNames from 'classnames' +import { LicenseInfo } from 'components/license-info/license-info' import { IconButton, IconButtonShape, @@ -11,7 +12,6 @@ import { getTotalLabel } from 'utils/numberFormats' import styles from './image-carousel.module.scss' import { CarouselTheme } from './types' import { getImageBoxStyles, getPlaceholderStyles } from './utils' -import { LicenseInfo } from 'components/license-info/license-info' interface ImageCarouselProps { autoPlay?: boolean @@ -93,7 +93,7 @@ const BasicImageCarousel = ({
{image ? ( - {image.alt} + {image.alt} ) : ( - {image.alt} + {image.alt}
) })} diff --git a/ui/src/design-system/components/table/image-table-cell/image-table-cell.module.scss b/ui/src/design-system/components/table/image-table-cell/image-table-cell.module.scss index 3b6b0a0ab..6f1c9d48c 100644 --- a/ui/src/design-system/components/table/image-table-cell/image-table-cell.module.scss +++ b/ui/src/design-system/components/table/image-table-cell/image-table-cell.module.scss @@ -2,5 +2,5 @@ padding: 12px 16px; display: flex; align-items: center; - justify-content: center; + justify-content: flex-start; } diff --git a/ui/src/pages/occurrences/occurrence-columns.tsx b/ui/src/pages/occurrences/occurrence-columns.tsx index 781ed6158..33ae4efea 100644 --- a/ui/src/pages/occurrences/occurrence-columns.tsx +++ b/ui/src/pages/occurrences/occurrence-columns.tsx @@ -1,3 +1,4 @@ +import { DeterminationScore } from 'components/determination-score' import { Occurrence } from 'data-services/models/occurrence' import { BasicTableCell } from 'design-system/components/table/basic-table-cell/basic-table-cell' import { ImageTableCell } from 'design-system/components/table/image-table-cell/image-table-cell' @@ -7,13 +8,11 @@ import { TableColumn, TextAlign, } from 'design-system/components/table/types' -import { BasicTooltip } from 'design-system/components/tooltip/basic-tooltip' -import { IdentificationScore, TaxonDetails } from 'nova-ui-kit' +import { TaxonDetails } from 'nova-ui-kit' import { Agree } from 'pages/occurrence-details/agree/agree' -import { TABS } from 'pages/occurrence-details/occurrence-details' import { IdQuickActions } from 'pages/occurrence-details/reject-id/id-quick-actions' import { SuggestIdPopover } from 'pages/occurrence-details/suggest-id/suggest-id-popover' -import { Link, useNavigate } from 'react-router-dom' +import { Link } from 'react-router-dom' import { APP_ROUTES } from 'utils/constants' import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' @@ -45,7 +44,7 @@ export const columns: ( return ( @@ -70,7 +69,22 @@ export const columns: ( name: translate(STRING.FIELD_LABEL_SCORE), sortField: 'determination_score', renderCell: (item: Occurrence) => ( - + + + ), }, { @@ -202,55 +216,3 @@ const TaxonCell = ({
) } - -const ScoreCell = ({ - item, - projectId, -}: { - item: Occurrence - projectId: string -}) => { - const navigate = useNavigate() - const detailsRoute = getAppRoute({ - to: APP_ROUTES.OCCURRENCE_DETAILS({ - projectId, - occurrenceId: item.id, - }), - keepSearchParams: true, - }) - - return ( -
- -
- - navigate(detailsRoute, { - state: { - defaultTab: TABS.IDENTIFICATION, - }, - }) - } - > - - - - {item.determinationScoreLabel} - -
-
-
- ) -} diff --git a/ui/src/pages/occurrences/occurrence-gallery.tsx b/ui/src/pages/occurrences/occurrence-gallery.tsx index f44bc8d6a..ca3a219eb 100644 --- a/ui/src/pages/occurrences/occurrence-gallery.tsx +++ b/ui/src/pages/occurrences/occurrence-gallery.tsx @@ -21,8 +21,7 @@ export const OccurrenceGallery = ({ occurrences.map((o) => ({ id: o.id, image: o.images[0], - subTitle: `(${o.determinationScore})`, - title: o.determinationTaxon.name, + title: `${o.determinationTaxon.name} (${o.determinationScore})`, to: getAppRoute({ to: APP_ROUTES.OCCURRENCE_DETAILS({ projectId: projectId as string, diff --git a/ui/src/pages/occurrences/occurrences.module.scss b/ui/src/pages/occurrences/occurrences.module.scss index 745fa4784..7b42a01f2 100644 --- a/ui/src/pages/occurrences/occurrences.module.scss +++ b/ui/src/pages/occurrences/occurrences.module.scss @@ -16,18 +16,3 @@ gap: 8px; } } - -.scoreCell { - .scoreCellContent { - display: flex; - align-items: center; - justify-content: flex-start; - gap: 12px; - } - - .scoreCellLabel { - display: block; - @include paragraph-medium(); - color: $color-neutral-700; - } -} diff --git a/ui/src/pages/species/species-columns.tsx b/ui/src/pages/species/species-columns.tsx index adc51cb8b..02dc11f7e 100644 --- a/ui/src/pages/species/species-columns.tsx +++ b/ui/src/pages/species/species-columns.tsx @@ -1,7 +1,10 @@ +import { DeterminationScore } from 'components/determination-score' import { Species } from 'data-services/models/species' import { BasicTableCell } from 'design-system/components/table/basic-table-cell/basic-table-cell' +import { ImageTableCell } from 'design-system/components/table/image-table-cell/image-table-cell' import { CellTheme, + ImageCellTheme, TableColumn, TextAlign, } from 'design-system/components/table/types' @@ -14,6 +17,20 @@ import { STRING, translate } from 'utils/language' export const columns: (projectId: string) => TableColumn[] = ( projectId: string ) => [ + { + id: 'cover-image', + name: 'Cover image', + sortField: 'cover_image_url', + renderCell: (item: Species) => { + return ( + + ) + }, + }, { id: 'name', sortField: 'name', @@ -31,6 +48,22 @@ export const columns: (projectId: string) => TableColumn[] = ( ), }, + { + id: 'rank', + name: 'Taxon rank', + styles: { + textAlign: TextAlign.Right, + }, + renderCell: (item: Species) => , + }, + { + id: 'last-seen', + sortField: 'last_detected', + name: 'Last seen', + renderCell: (item: Species) => ( + + ), + }, { id: 'occurrences', sortField: 'occurrences_count', @@ -45,46 +78,36 @@ export const columns: (projectId: string) => TableColumn[] = ( filters: { taxon: item.id }, })} > - + ), }, { - id: 'score', - sortField: 'best_determination_score', + id: 'best-determination-score', name: translate(STRING.FIELD_LABEL_BEST_SCORE), - styles: { - textAlign: TextAlign.Right, - }, + sortField: 'best_determination_score', renderCell: (item: Species) => ( - + + + ), }, { - id: 'rank', - sortField: 'rank', - name: 'Taxon rank', - styles: { - textAlign: TextAlign.Right, - }, - renderCell: (item: Species) => , + id: 'created-at', + name: translate(STRING.FIELD_LABEL_CREATED_AT), + sortField: 'created_at', + renderCell: (item: Species) => , }, { - id: 'training-images', - name: translate(STRING.FIELD_LABEL_TRAINING_IMAGES), - styles: { - textAlign: TextAlign.Right, - }, - renderCell: (item: Species) => ( - - - - ), + id: 'updated-at', + name: translate(STRING.FIELD_LABEL_UPDATED_AT), + sortField: 'updated_at', + renderCell: (item: Species) => , }, ] diff --git a/ui/src/pages/species/species-gallery.tsx b/ui/src/pages/species/species-gallery.tsx index 35af26123..56c02f201 100644 --- a/ui/src/pages/species/species-gallery.tsx +++ b/ui/src/pages/species/species-gallery.tsx @@ -20,7 +20,7 @@ export const SpeciesGallery = ({ () => species.map((s) => ({ id: s.id, - image: s.images[0], + image: s.coverImage ? { src: s.coverImage.url } : undefined, title: s.name, to: getAppRoute({ to: APP_ROUTES.TAXON_DETAILS({ diff --git a/ui/src/pages/species/species.tsx b/ui/src/pages/species/species.tsx index fa792f75a..cf7dc2acd 100644 --- a/ui/src/pages/species/species.tsx +++ b/ui/src/pages/species/species.tsx @@ -8,6 +8,7 @@ import { IconType } from 'design-system/components/icon/icon' import { PageFooter } from 'design-system/components/page-footer/page-footer' import { PageHeader } from 'design-system/components/page-header/page-header' import { PaginationBar } from 'design-system/components/pagination-bar/pagination-bar' +import { ColumnSettings } from 'design-system/components/table/column-settings/column-settings' import { Table } from 'design-system/components/table/table/table' import { ToggleGroup } from 'design-system/components/toggle-group/toggle-group' import { SpeciesDetails } from 'pages/species-details/species-details' @@ -17,8 +18,10 @@ import { BreadcrumbContext } from 'utils/breadcrumbContext' import { APP_ROUTES } from 'utils/constants' import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' +import { useColumnSettings } from 'utils/useColumnSettings' import { useFilters } from 'utils/useFilters' import { usePagination } from 'utils/usePagination' +import { useUserPreferences } from 'utils/userPreferences/userPreferencesContext' import { useSelectedView } from 'utils/useSelectedView' import { useSort } from 'utils/useSort' import { columns } from './species-columns' @@ -26,9 +29,22 @@ import { SpeciesGallery } from './species-gallery' export const Species = () => { const { projectId, id } = useParams() + const { columnSettings, setColumnSettings } = useColumnSettings('species', { + 'cover-image': true, + name: true, + rank: false, + 'last-seen': true, + occurrences: true, + 'best-determination-score': true, + 'created-at': false, + 'updated-at': false, + }) + const { userPreferences } = useUserPreferences() const { sort, setSort } = useSort({ field: 'name', order: 'asc' }) const { pagination, setPage } = usePagination() - const { filters } = useFilters() + const { filters } = useFilters({ + best_determination_score: `${userPreferences.scoreThreshold}`, + }) const { species, total, isLoading, isFetching, error } = useSpecies({ projectId, sort, @@ -48,6 +64,8 @@ export const Species = () => { {taxaLists.length > 0 && ( )} + +
{ value={selectedView} onValueChange={setSelectedView} /> + {selectedView === 'table' && ( !!columnSettings[column.id] + )} error={error} isLoading={!id && isLoading} items={species} diff --git a/ui/src/utils/useFilters.ts b/ui/src/utils/useFilters.ts index 07d03d1be..1abb52753 100644 --- a/ui/src/utils/useFilters.ts +++ b/ui/src/utils/useFilters.ts @@ -1,5 +1,6 @@ import { isBefore, isValid } from 'date-fns' import { useSearchParams } from 'react-router-dom' +import { STRING, translate } from './language' import { SEARCH_PARAM_KEY_PAGE } from './usePagination' export const AVAILABLE_FILTERS: { @@ -15,7 +16,7 @@ export const AVAILABLE_FILTERS: { field: 'algorithm', }, { - label: 'Score threshold', + label: translate(STRING.FIELD_LABEL_SCORE_THRESHOLD), field: 'classification_threshold', }, { @@ -112,6 +113,14 @@ export const AVAILABLE_FILTERS: { label: 'Verified by', field: 'verified_by_me', }, + { + label: 'Show unobserved taxa', + field: 'include_unobserved', + }, + { + label: 'Best score threshold', + field: 'best_determination_score', + }, ] export const useFilters = (defaultFilters?: { [field: string]: string }) => { diff --git a/ui/src/utils/userPreferences/userPreferencesContext.tsx b/ui/src/utils/userPreferences/userPreferencesContext.tsx index 71df6e033..d58bd75e9 100644 --- a/ui/src/utils/userPreferences/userPreferencesContext.tsx +++ b/ui/src/utils/userPreferences/userPreferencesContext.tsx @@ -26,7 +26,10 @@ export const UserPreferencesContextProvider = ({ return DEFAULT_PREFERENCES } try { - return JSON.parse(storedPreferences) as UserPreferences + return { + ...DEFAULT_PREFERENCES, + ...JSON.parse(storedPreferences), + } } catch { return DEFAULT_PREFERENCES } From dfe4940991cf65792f9691e53e81f68ea3f13d93 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 25 Jun 2025 12:00:45 +0200 Subject: [PATCH 06/11] feat: update species detail view with more information --- .../blueprint-collection.module.scss | 13 +- .../blueprint-collection.tsx | 39 ++-- .../info-block/info-block.module.scss | 32 +--- .../components/info-block/info-block.tsx | 81 +++++--- .../occurrence-details/occurrence-details.tsx | 56 ++++-- .../species-details.module.scss | 22 ++- .../pages/species-details/species-details.tsx | 179 ++++++++++++------ 7 files changed, 267 insertions(+), 155 deletions(-) diff --git a/ui/src/components/blueprint-collection/blueprint-collection.module.scss b/ui/src/components/blueprint-collection/blueprint-collection.module.scss index 6f8974c3c..a532fdeb2 100644 --- a/ui/src/components/blueprint-collection/blueprint-collection.module.scss +++ b/ui/src/components/blueprint-collection/blueprint-collection.module.scss @@ -82,16 +82,23 @@ } } -@media only screen and (max-width: $small-screen-breakpoint) { +@media (max-width: $small-screen-breakpoint) { .licenseInfoContent { text-align: left; } .blueprintContent { - flex-direction: row; - &.empty { display: none; } } } + +@media print { + .blueprintContent { + display: grid; + align-items: start; + grid-template-columns: 1fr 1fr; + gap: 24px; + } +} diff --git a/ui/src/components/blueprint-collection/blueprint-collection.tsx b/ui/src/components/blueprint-collection/blueprint-collection.tsx index e7f959da7..e56172ea4 100644 --- a/ui/src/components/blueprint-collection/blueprint-collection.tsx +++ b/ui/src/components/blueprint-collection/blueprint-collection.tsx @@ -3,7 +3,7 @@ import { LicenseInfo } from 'components/license-info/license-info' import { Icon, IconType } from 'design-system/components/icon/icon' import { EyeIcon } from 'lucide-react' import { buttonVariants, Tooltip } from 'nova-ui-kit' -import { useState } from 'react' +import { ReactNode, useState } from 'react' import { Link } from 'react-router-dom' import styles from './blueprint-collection.module.scss' @@ -16,26 +16,35 @@ export interface BlueprintItem { to?: string } -export const BlueprintCollection = ({ items }: { items: BlueprintItem[] }) => ( -
- {items.length > 0 && ( +export const BlueprintCollection = ({ + children, + showLicenseInfo, +}: { + children: ReactNode + showLicenseInfo?: boolean +}) => ( +
+ {showLicenseInfo ? (
- )} -
- {items.map((item) => ( - - ))} -
+ ) : null} +
{children}
) -const BlueprintItem = ({ item }: { item: BlueprintItem }) => { +export const BlueprintItem = ({ + item, +}: { + item: { + id: string + image: { src: string; width: number; height: number } + label: string + timeLabel: string + countLabel: string + to?: string + } +}) => { const [size, setSize] = useState({ width: item.image.width, height: item.image.height, diff --git a/ui/src/design-system/components/info-block/info-block.module.scss b/ui/src/design-system/components/info-block/info-block.module.scss index be1d15a66..a1e9b3ee1 100644 --- a/ui/src/design-system/components/info-block/info-block.module.scss +++ b/ui/src/design-system/components/info-block/info-block.module.scss @@ -1,34 +1,10 @@ @import 'src/design-system/variables/colors.scss'; @import 'src/design-system/variables/typography.scss'; -.field { - margin: 0; +.bubble { + @include bubble-label(); - &:not(:last-child) { - margin-bottom: 16px; - } -} - -.fieldLabel { - display: block; - @include label(); - font-weight: 600; - color: $color-neutral-300; -} - -.fieldValue { - display: block; - @include paragraph-medium(); - color: $color-neutral-700; - - &.link { - color: $color-primary-1-600; - font-weight: 600; - } - - &.bubble { - @include bubble-label(); - display: inline-flex; - margin-top: 4px; + @media not print { + margin: 4px 0; } } diff --git a/ui/src/design-system/components/info-block/info-block.tsx b/ui/src/design-system/components/info-block/info-block.tsx index 4dd162505..b6693ab87 100644 --- a/ui/src/design-system/components/info-block/info-block.tsx +++ b/ui/src/design-system/components/info-block/info-block.tsx @@ -1,5 +1,6 @@ import classNames from 'classnames' import _ from 'lodash' +import { ReactNode } from 'react' import { Link } from 'react-router-dom' import { STRING, translate } from 'utils/language' import styles from './info-block.module.scss' @@ -11,32 +12,58 @@ interface Field { } export const InfoBlock = ({ fields }: { fields: Field[] }) => ( - <> - {fields.map((field, index) => { - const value = - field.value === undefined - ? translate(STRING.VALUE_NOT_AVAILABLE) - : field.value - const valueLabel = _.isNumber(value) ? value.toLocaleString() : value +
+ {fields.map((field, index) => ( + + + + ))} +
+) - return ( -

- {field.label} - {field.to ? ( - - - {valueLabel} - - - ) : ( - {valueLabel} - )} -

- ) - })} - +export const InfoBlockField = ({ + children, + className, + label, +}: { + children: ReactNode + className?: string + label: string +}) => ( +
+ + {label} + + {children} +
) + +export const InfoBlockFieldValue = ({ + value, + to, +}: { + value?: string | number + to?: string +}) => { + const _value = + value === undefined ? translate(STRING.VALUE_NOT_AVAILABLE) : value + const valueLabel = _.isNumber(_value) ? _value.toLocaleString() : _value + + return ( + <> + {to ? ( + + + {valueLabel} + + + ) : ( + {valueLabel} + )} + + ) +} diff --git a/ui/src/pages/occurrence-details/occurrence-details.tsx b/ui/src/pages/occurrence-details/occurrence-details.tsx index 6a5c38b67..2c9b85e8e 100644 --- a/ui/src/pages/occurrence-details/occurrence-details.tsx +++ b/ui/src/pages/occurrence-details/occurrence-details.tsx @@ -3,7 +3,10 @@ import { BlueprintItem, } from 'components/blueprint-collection/blueprint-collection' import { OccurrenceDetails as Occurrence } from 'data-services/models/occurrence-details' -import { InfoBlock } from 'design-system/components/info-block/info-block' +import { + InfoBlockField, + InfoBlockFieldValue, +} from 'design-system/components/info-block/info-block' import * as Tabs from 'design-system/components/tabs/tabs' import { BasicTooltip } from 'design-system/components/tooltip/basic-tooltip' import { SearchIcon } from 'lucide-react' @@ -143,22 +146,24 @@ export const OccurrenceDetails = ({ taxon={occurrence.determinationTaxon} />
- - - + {occurrence.determinationScore !== undefined ? ( + + + + ) : null} {canUpdate && ( <> - +
+ {fields.map((field, index) => ( + + + + ))} +
@@ -276,7 +290,11 @@ export const OccurrenceDetails = ({
- + 0}> + {blueprintItems.map((item) => ( + + ))} +
diff --git a/ui/src/pages/species-details/species-details.module.scss b/ui/src/pages/species-details/species-details.module.scss index a29659f6a..1f31f415d 100644 --- a/ui/src/pages/species-details/species-details.module.scss +++ b/ui/src/pages/species-details/species-details.module.scss @@ -8,19 +8,23 @@ height: 100%; } -.content { - max-width: 100%; - display: flex; - flex: 1; -} - .header { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; padding: 24px; background-color: $color-neutral-50; border-bottom: 2px solid $color-neutral-100; z-index: 1; } +.content { + max-width: 100%; + display: flex; + flex: 1; +} + .info { width: 448px; padding: 24px; @@ -40,7 +44,7 @@ overflow-y: auto; } -@media only screen and (max-width: $small-screen-breakpoint) { +@media (max-width: $small-screen-breakpoint) { .wrapper { width: 100%; height: 100%; @@ -51,6 +55,10 @@ flex-direction: column; } + .info { + width: auto; + } + .blueprintWrapper { width: auto; } diff --git a/ui/src/pages/species-details/species-details.tsx b/ui/src/pages/species-details/species-details.tsx index e369cd90d..07154a13c 100644 --- a/ui/src/pages/species-details/species-details.tsx +++ b/ui/src/pages/species-details/species-details.tsx @@ -1,13 +1,14 @@ -import { - BlueprintCollection, - BlueprintItem, -} from 'components/blueprint-collection/blueprint-collection' +import { BlueprintCollection } from 'components/blueprint-collection/blueprint-collection' +import { DeterminationScore } from 'components/determination-score' import { SpeciesDetails as Species } from 'data-services/models/species-details' -import { InfoBlock } from 'design-system/components/info-block/info-block' -import { TaxonDetails } from 'nova-ui-kit' -import { useMemo } from 'react' +import { + InfoBlockField, + InfoBlockFieldValue, +} from 'design-system/components/info-block/info-block' +import { ExternalLinkIcon } from 'lucide-react' +import { buttonVariants, TaxonDetails } from 'nova-ui-kit' import { Helmet } from 'react-helmet-async' -import { useNavigate, useParams } from 'react-router-dom' +import { Link, useNavigate, useParams } from 'react-router-dom' import { APP_ROUTES } from 'utils/constants' import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' @@ -16,52 +17,12 @@ import styles from './species-details.module.scss' export const SpeciesDetails = ({ species }: { species: Species }) => { const { projectId } = useParams() const navigate = useNavigate() - - const image = useMemo(() => { - if (species.occurrences.length) { - const occurrenceInfo = species.getOccurrenceInfo(species.occurrences[0]) - return occurrenceInfo?.image.src - } - }, [species]) - - const blueprintItems = useMemo( - () => - species.occurrences.length - ? species.occurrences - .map((id) => species.getOccurrenceInfo(id)) - .filter((item): item is BlueprintItem => !!item) - .map((item) => ({ - ...item, - to: APP_ROUTES.OCCURRENCE_DETAILS({ - projectId: projectId as string, - occurrenceId: item.id, - }), - })) - : [], - [species] - ) - - const fields = [ - { - label: translate(STRING.FIELD_LABEL_OCCURRENCES), - value: - species.numOccurrences !== null ? species.numOccurrences : 'View all', - to: getAppRoute({ - to: APP_ROUTES.OCCURRENCES({ projectId: projectId as string }), - filters: { taxon: species.id }, - }), - }, - { - label: translate(STRING.FIELD_LABEL_TRAINING_IMAGES), - value: species.trainingImagesLabel, - to: species.trainingImagesUrl, - }, - ].filter((field) => field.value !== null) + const hasChildren = species.rank !== 'SPECIES' return (
- +
{ />
-
-
-
- -
+
+
+ + + + {hasChildren ? ( + + + + ) : null} + + + + + + + + +
+ + GBIF + + + {species.fieldguideUrl ? ( + + Fieldguide + + + ) : null} +
+
- + + {species.coverImage && + species.coverImage.url !== species.exampleOccurrence?.url ? ( + + + + + + {species.coverImage.caption} + + + ) : null} + {species.exampleOccurrence ? ( + + + + + {species.exampleOccurrence.caption ? ( + + {species.exampleOccurrence.caption} + + ) : undefined} + + ) : null} +
From 59eb5d2ac7fb7e6fc93dc4cc440022c8ca1b7b26 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 25 Jun 2025 12:02:01 +0200 Subject: [PATCH 07/11] copy: tweak score labels --- .../identification-card/machine-prediction.tsx | 2 +- ui/src/utils/language.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/pages/occurrence-details/identification-card/machine-prediction.tsx b/ui/src/pages/occurrence-details/identification-card/machine-prediction.tsx index 2cdaf6682..11a1d824c 100644 --- a/ui/src/pages/occurrence-details/identification-card/machine-prediction.tsx +++ b/ui/src/pages/occurrence-details/identification-card/machine-prediction.tsx @@ -162,7 +162,7 @@ const MachinePredictionDetails = ({
diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index fb680b9fd..2f11da5cb 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -543,7 +543,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.ID_APPLIED]: 'ID applied', [STRING.LAST_UPDATED]: 'Last updated', [STRING.LOADING_DATA]: 'Loading data', - [STRING.MACHINE_PREDICTION_SCORE]: 'Machine prediction\nscore {{score}}', + [STRING.MACHINE_PREDICTION_SCORE]: 'Machine prediction score\n{{score}}', [STRING.MACHINE_SUGGESTION]: 'Machine suggestion', [STRING.TERMINAL_CLASSIFICATION]: 'Terminal classification', [STRING.INTERMEDIATE_CLASSIFICATION]: 'Intermediate classification', From 3005c5c498e0a9d4f7868309692f80bb751f847d Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 25 Jun 2025 12:02:51 +0200 Subject: [PATCH 08/11] style: cleanup --- .../toggle-group/toggle-group.module.scss | 2 +- .../components/tooltip/basic-tooltip.tsx | 4 +++- .../algorithm-details-dialog.tsx | 4 ++-- .../pages/occurrences/occurrence-columns.tsx | 2 +- .../pages/species-details/species-details.tsx | 24 ++++++++++--------- ui/src/pages/species/species-columns.tsx | 2 +- 6 files changed, 21 insertions(+), 17 deletions(-) diff --git a/ui/src/design-system/components/toggle-group/toggle-group.module.scss b/ui/src/design-system/components/toggle-group/toggle-group.module.scss index 7c7a6eb79..403a39b2c 100644 --- a/ui/src/design-system/components/toggle-group/toggle-group.module.scss +++ b/ui/src/design-system/components/toggle-group/toggle-group.module.scss @@ -15,7 +15,7 @@ display: flex; align-items: center; justify-content: center; - aspect-ratio: 1; + width: 28px; height: 100%; outline: none; diff --git a/ui/src/design-system/components/tooltip/basic-tooltip.tsx b/ui/src/design-system/components/tooltip/basic-tooltip.tsx index afa2b8be1..6963c38a1 100644 --- a/ui/src/design-system/components/tooltip/basic-tooltip.tsx +++ b/ui/src/design-system/components/tooltip/basic-tooltip.tsx @@ -25,7 +25,9 @@ export const BasicTooltip = ({ {children} - {content} + + {content} + diff --git a/ui/src/pages/algorithm-details/algorithm-details-dialog.tsx b/ui/src/pages/algorithm-details/algorithm-details-dialog.tsx index 3bf40b6b7..b5543aeb8 100644 --- a/ui/src/pages/algorithm-details/algorithm-details-dialog.tsx +++ b/ui/src/pages/algorithm-details/algorithm-details-dialog.tsx @@ -106,7 +106,7 @@ const AlgorithmDetailsContent = ({ algorithm }: { algorithm: Algorithm }) => ( target="_blank" > {translate(STRING.FIELD_LABEL_ALGORITHM_URI)} - + )} {algorithm.categoryMapURI && ( @@ -120,7 +120,7 @@ const AlgorithmDetailsContent = ({ algorithm }: { algorithm: Algorithm }) => ( target="_blank" > {translate(STRING.FIELD_LABEL_CATEGORY_MAP_DETAILS)} - + )}
diff --git a/ui/src/pages/occurrences/occurrence-columns.tsx b/ui/src/pages/occurrences/occurrence-columns.tsx index 33ae4efea..0b13b63db 100644 --- a/ui/src/pages/occurrences/occurrence-columns.tsx +++ b/ui/src/pages/occurrences/occurrence-columns.tsx @@ -186,7 +186,7 @@ const TaxonCell = ({ return (
- +
diff --git a/ui/src/pages/species-details/species-details.tsx b/ui/src/pages/species-details/species-details.tsx index 07154a13c..898c4b2e0 100644 --- a/ui/src/pages/species-details/species-details.tsx +++ b/ui/src/pages/species-details/species-details.tsx @@ -74,17 +74,19 @@ export const SpeciesDetails = ({ species }: { species: Species }) => { /> - +
+ +
TableColumn[] = ( keepSearchParams: true, })} > - + From 61a2bf81a709f1bb64f5ae0aace90980b7715fc6 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Sun, 11 May 2025 09:00:10 -0600 Subject: [PATCH 09/11] Backend support for OOD score threshold filter (#840) * feat: generic ThresholdFilter for OOD and determination scores * docs: update openAPI schema docs with new filters for occurrence list --- ami/base/filters.py | 56 ++++++++++++++++++++++++++++++++++++++++++- ami/main/api/views.py | 51 ++++++++++++++++++++++++++++++--------- 2 files changed, 95 insertions(+), 12 deletions(-) diff --git a/ami/base/filters.py b/ami/base/filters.py index 40a030b59..c3c18a038 100644 --- a/ami/base/filters.py +++ b/ami/base/filters.py @@ -1,5 +1,6 @@ from django.db.models import F, OrderBy -from rest_framework.filters import OrderingFilter +from django.forms import FloatField +from rest_framework.filters import BaseFilterBackend, OrderingFilter class NullsLastOrderingFilter(OrderingFilter): @@ -8,3 +9,56 @@ def get_ordering(self, request, queryset, view): if not values: return values return [OrderBy(F(value.lstrip("-")), descending=value.startswith("-"), nulls_last=True) for value in values] + + +class ThresholdFilter(BaseFilterBackend): + """ + Filter a numeric field by a minimum value. + + Usage: + + Filter occurrences by their determination score: + GET /occurrences/?score=0.5 + This will return all occurrences with a determination score greater than or equal to 0.5. + + Customize the query_param and filter_param to match your API and model fields using + the create method. + + Example: + + DeterminationScoreFilter = ThresholdFilter.create( + query_param="classification_threshold", + filter_param="determination_score", + ) + OODScoreFilter = ThresholdFilter.create("determination_ood_score") + + class OccurrenceViewSet(DefaultViewSet): + filter_backends = DefaultViewSetMixin.filter_backends + [ + DeterminationScoreFilter, + OODScoreFilter, + ] + """ + + query_param = "score" + filter_param = "score" + + def filter_queryset(self, request, queryset, view): + value = FloatField(required=False).clean(request.query_params.get(self.query_param)) + if value: + filters = {f"{self.filter_param}__gte": value} + queryset = queryset.filter(**filters) + return queryset + + @classmethod + def create(cls, query_param: str, filter_param: str | None = None) -> type["ThresholdFilter"]: + class_name = f"{cls.__name__}_{query_param}" + if filter_param is None: + filter_param = query_param + return type( + class_name, + (cls,), + { + "query_param": query_param, + "filter_param": filter_param, + }, + ) diff --git a/ami/main/api/views.py b/ami/main/api/views.py index 5ef252b3f..8786aa5a6 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -23,7 +23,7 @@ from rest_framework.response import Response from rest_framework.views import APIView -from ami.base.filters import NullsLastOrderingFilter +from ami.base.filters import NullsLastOrderingFilter, ThresholdFilter from ami.base.pagination import LimitOffsetPaginationWithPermissions from ami.base.permissions import ( CanDeleteIdentification, @@ -1024,6 +1024,12 @@ def filter_queryset(self, request, queryset, view): return queryset +OccurrenceDeterminationScoreFilter = ThresholdFilter.create( + query_param="classification_threshold", filter_param="determination_score" +) +OccurrenceOODScoreFilter = ThresholdFilter.create("determination_ood_score") + + class OccurrenceViewSet(DefaultViewSet, ProjectMixin): """ API endpoint that allows occurrences to be viewed or edited. @@ -1041,6 +1047,8 @@ class OccurrenceViewSet(DefaultViewSet, ProjectMixin): OccurrenceVerified, OccurrenceVerifiedByMeFilter, OccurrenceTaxaListFilter, + OccurrenceDeterminationScoreFilter, + OccurrenceOODScoreFilter, ] filterset_fields = [ "event", @@ -1060,7 +1068,6 @@ class OccurrenceViewSet(DefaultViewSet, ProjectMixin): "determination_score", "event", "detections_count", - "created_at", ] def get_serializer_class(self): @@ -1085,14 +1092,7 @@ def get_queryset(self) -> QuerySet["Occurrence"]: qs = qs.with_detections_count().with_timestamps() # type: ignore qs = qs.with_identifications() # type: ignore - if self.action == "list": - qs = ( - qs.all() - .filter(determination_score__gte=get_active_classification_threshold(self.request)) - .order_by("-determination_score") - ) - - else: + if self.action != "list": qs = qs.prefetch_related( Prefetch( "detections", queryset=Detection.objects.order_by("-timestamp").select_related("source_image") @@ -1101,7 +1101,36 @@ def get_queryset(self) -> QuerySet["Occurrence"]: return qs - @extend_schema(parameters=[project_id_doc_param]) + @extend_schema( + parameters=[ + project_id_doc_param, + OpenApiParameter( + name="classification_threshold", + description="Filter occurrences by minimum determination score.", + required=False, + type=OpenApiTypes.FLOAT, + ), + OpenApiParameter( + name="determination_ood_score", + description="Filter occurrences by minimum out-of-distribution score.", + required=False, + type=OpenApiTypes.FLOAT, + ), + OpenApiParameter( + name="taxon", + description="Filter occurrences by determination taxon ID. Shows occurrences determined as this taxon " + "or any of its child taxa.", + required=False, + type=OpenApiTypes.INT, + ), + OpenApiParameter( + name="collection_id", + description="Filter occurrences by the collection their detections' source images belong to.", + required=False, + type=OpenApiTypes.INT, + ), + ] + ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) From 0a276be0b44b14b39353ea46dc165e8b127a1161 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 2 Jul 2025 12:38:19 -0700 Subject: [PATCH 10/11] feat: add backend for threshold filters (best score for taxa) --- ami/main/api/views.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/ami/main/api/views.py b/ami/main/api/views.py index 8786aa5a6..6931eddee 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -1027,7 +1027,6 @@ def filter_queryset(self, request, queryset, view): OccurrenceDeterminationScoreFilter = ThresholdFilter.create( query_param="classification_threshold", filter_param="determination_score" ) -OccurrenceOODScoreFilter = ThresholdFilter.create("determination_ood_score") class OccurrenceViewSet(DefaultViewSet, ProjectMixin): @@ -1048,7 +1047,6 @@ class OccurrenceViewSet(DefaultViewSet, ProjectMixin): OccurrenceVerifiedByMeFilter, OccurrenceTaxaListFilter, OccurrenceDeterminationScoreFilter, - OccurrenceOODScoreFilter, ] filterset_fields = [ "event", @@ -1110,12 +1108,6 @@ def get_queryset(self) -> QuerySet["Occurrence"]: required=False, type=OpenApiTypes.FLOAT, ), - OpenApiParameter( - name="determination_ood_score", - description="Filter occurrences by minimum out-of-distribution score.", - required=False, - type=OpenApiTypes.FLOAT, - ), OpenApiParameter( name="taxon", description="Filter occurrences by determination taxon ID. Shows occurrences determined as this taxon " @@ -1162,6 +1154,9 @@ def filter_queryset(self, request, queryset, view): return queryset +TaxonBestScoreFilter = ThresholdFilter.create("best_determination_score") + + class TaxonViewSet(DefaultViewSet, ProjectMixin): """ API endpoint that allows taxa to be viewed or edited. @@ -1173,6 +1168,7 @@ class TaxonViewSet(DefaultViewSet, ProjectMixin): CustomTaxonFilter, TaxonCollectionFilter, TaxonTaxaListFilter, + TaxonBestScoreFilter, ] filterset_fields = [ "name", From 2c97e2d43f262232647382c01590d3a091850d41 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 2 Jul 2025 12:43:46 -0700 Subject: [PATCH 11/11] feat: add missing fields to taxon detail serializer --- ami/main/api/serializers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ami/main/api/serializers.py b/ami/main/api/serializers.py index 7964ad16f..8d462a103 100644 --- a/ami/main/api/serializers.py +++ b/ami/main/api/serializers.py @@ -731,6 +731,8 @@ class Meta: "events_count", "occurrences", "gbif_taxon_key", + "last_detected", + "best_determination_score", ]