From 4fbd4bac69ee3d0b222d10aeca4141b95c9f1c0e Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Tue, 9 Dec 2025 23:25:08 +0100 Subject: [PATCH 01/10] test a new connector for the GBIF using the facet params --- src/components/core/filters/SourceFilter.vue | 2 + src/lib/connectors/connectors.ts | 1 + src/lib/connectors/gbiffacet.ts | 380 +++++++++++++++++++ src/lib/connectors/utils.ts | 5 +- 4 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 src/lib/connectors/gbiffacet.ts diff --git a/src/components/core/filters/SourceFilter.vue b/src/components/core/filters/SourceFilter.vue index b5debd10..fb7ddc5b 100644 --- a/src/components/core/filters/SourceFilter.vue +++ b/src/components/core/filters/SourceFilter.vue @@ -5,6 +5,7 @@ import { getConnector } from '@/lib/connectors/utils'; import { GeoNatureConnector } from '@/lib/connectors/geonature'; import { GbifConnector } from '@/lib/connectors/gbif'; + import { GbifFacetConnector } from '@/lib/connectors/gbiffacet'; import { CONNECTORS } from '@/lib/connectors/connectors'; const props = defineProps({ @@ -19,6 +20,7 @@ const sourcesParams = { [CONNECTORS.GBIF]: new GbifConnector().getParamsSchema(), [CONNECTORS.GeoNature]: new GeoNatureConnector().getParamsSchema(), + [CONNECTORS.GBIF_FACET]: new GbifFacetConnector().getParamsSchema(), }; const sourceName = ref(connector.value.name); diff --git a/src/lib/connectors/connectors.ts b/src/lib/connectors/connectors.ts index ebe2c080..5c90d27d 100644 --- a/src/lib/connectors/connectors.ts +++ b/src/lib/connectors/connectors.ts @@ -1,4 +1,5 @@ export enum CONNECTORS { GBIF = 'GBIF', GeoNature = 'GeoNature', + GBIF_FACET = 'GBIF_FACET', } diff --git a/src/lib/connectors/gbiffacet.ts b/src/lib/connectors/gbiffacet.ts new file mode 100644 index 00000000..c6b1e587 --- /dev/null +++ b/src/lib/connectors/gbiffacet.ts @@ -0,0 +1,380 @@ +import { Connector, ConnectorOptions } from './connector'; +import { Dataset, SearchResult, Taxon } from '../models'; +import ParameterStore from '../parameterStore'; +import { NO_IMAGE_URL } from '@/assets/constant'; +import { TAXON_REFERENTIAL } from '../taxonReferential'; +import { getMediaSource, SOURCE_ } from '../media/media'; +import { useI18n } from 'vue-i18n'; +import { CONNECTORS } from './connectors'; +import { GBIFSearchScoring } from './search/gbif'; + +const GBIF_ENDPOINT_DEFAULT = 'https://api.gbif.org/v1'; +const GBIF_DEFAULT_TAXON_LIMIT = 1000; + +type OccurrenceParams = Record; + +export class GbifFacetConnector extends Connector { + name: string; + GBIF_ENDPOINT: string; + LIMIT: number; + + constructor(options: ConnectorOptions) { + super(options); + this.name = CONNECTORS.GBIF_FACET; + + // specific parameters + this.GBIF_ENDPOINT = + this.options?.GBIF_ENDPOINT || GBIF_ENDPOINT_DEFAULT; + this.LIMIT = this.options?.LIMIT || GBIF_DEFAULT_TAXON_LIMIT; + + this.referential = TAXON_REFERENTIAL.GBIF; + + this.imageSource = this.imageSource || getMediaSource(SOURCE_.wikidata); + this.soundSource = this.soundSource || getMediaSource(SOURCE_.wikidata); + + this.taxonClass2SourceID = { + Aves: 212, + Mammalia: 359, + Reptilia: 358, + Amphibia: 131, + Insecta: 216, + Arachnida: 367, + Gastropoda: 225, + Bivalvia: 137, + Magnoliopsida: 220, + Liliopsida: 196, + Pinopsida: 194, + }; + this.isSearchOnAPIAvailable = true; + this.scoringSearchClass = new GBIFSearchScoring(); + } + + getParamsSchema(): Array> { + const { t } = useI18n(); + return [ + { + name: 'GBIF_ENDPOINT', + label: "Adresse de l'API du GBIF", + type: String, + default: 'https://api.gbif.org/v1', + }, + { + name: 'LIMIT', + label: t('limit'), + type: Number, + default: GBIF_DEFAULT_TAXON_LIMIT, + }, + ]; + } + + fetchVernacularName(taxonID: string | number): Promise { + const mapping_language: Record = { + en: 'eng', + fr: 'fra', + es: 'spa', + cs: 'ces', + it: 'ita', + de: 'deu', + }; + const currentLanguage = ParameterStore.getInstance().lang.value; + return fetch( + `${this.GBIF_ENDPOINT}/species/${taxonID}/vernacularNames?limit=100` + ) + .then((response) => response.json()) + .then((data) => { + const nameData = data.results.find( + (nameData: any) => + nameData.language === mapping_language[currentLanguage] + ); + return nameData + ? nameData.vernacularName.charAt(0).toUpperCase() + + nameData.vernacularName.slice(1) + : undefined; + }); + } + + fetchOccurrence(params: any = {}): Promise { + super.fetchOccurrence(params); + + const searchParams = this.buildSearchParams(params); + const urlWithParams = this.buildSearchUrl(searchParams); + + return fetch(urlWithParams.toString()) + .then((response) => response.json()) + .then(async (data) => { + const taxonsData: Record = {}; + const datasetData: Record = {}; + + if (data.facets && data.facets.length > 0) { + await this.processTaxonFacets(data.facets, taxonsData); + this.processDatasetFacets(data.facets, datasetData); + } + + return { + taxons: Object.values(taxonsData), + datasets: Object.values(datasetData), + }; + }); + } + + /** + * Builds search parameters for the GBIF API. + * Merges default parameters with user-provided ones. + * Handles date transformation and taxonomic class mapping. + * + * @param params - Custom search parameters + * @returns Formatted parameters for the GBIF API + */ + private buildSearchParams(params: any): OccurrenceParams { + let searchParams = { + occurrenceStatus: 'PRESENT', + basisOfRecord: [ + 'OBSERVATION', + 'HUMAN_OBSERVATION', + 'MACHINE_OBSERVATION', + 'OCCURRENCE', + ], + hasGeospatialIssue: false, + hasCoordinate: true, + facet: ['taxonKey', 'datasetKey'], + facetLimit: this.LIMIT, + limit: 0, + ...params, + }; + + // Gestion de la plage de dates + if (searchParams.dateMin && searchParams.dateMax) { + searchParams.eventDate = `${searchParams.dateMin},${searchParams.dateMax}`; + delete searchParams.dateMin; + delete searchParams.dateMax; + } + + // Gestion de la classe taxonomique + if (params?.class) { + searchParams.classKey = this.taxonClass2SourceID[params.class]; + delete searchParams.class; + } + + return searchParams; + } + + /** + * Builds the complete URL for the GBIF search query. + * Properly encodes simple parameters and arrays. + * + * @param params - Search parameters to encode in the URL + * @returns Complete URL with all parameters + */ + private buildSearchUrl(params: OccurrenceParams): URL { + const url = new URL(`${this.GBIF_ENDPOINT}/occurrence/search`); + + Object.entries(params).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((item) => url.searchParams.append(key, item)); + } else { + url.searchParams.append(key, value); + } + }); + + return url; + } + + /** + * Processes taxon facets returned by the GBIF API. + * Retrieves detailed information for each taxon in parallel. + * Filters taxa based on their taxonomic rank. + * + * @param facets - List of facets returned by the API + * @param taxonsData - Object to store validated taxon data + */ + private async processTaxonFacets( + facets: any[], + taxonsData: Record + ): Promise { + const taxonFacet = facets.find((f: any) => f.field === 'TAXON_KEY'); + + if (!taxonFacet?.counts) { + return; + } + + const taxonPromises = taxonFacet.counts.map((facetItem: any) => + this.processSingleTaxon(facetItem, taxonsData) + ); + + await Promise.all(taxonPromises); // TODO : watch out for performance + } + + /** + * Processes an individual taxon from the facet. + * Retrieves its information from the GBIF API and validates its taxonomic rank. + * If the rank is valid, adds the taxon to the collection. + * + * @param facetItem - Facet item containing the taxonKey and count + * @param taxonsData - Collection to add the taxon to if valid + */ + private async processSingleTaxon( + facetItem: any, + taxonsData: Record + ): Promise { + const taxonKey = facetItem.name; + const nbObservations = facetItem.count; + + try { + const taxonInfo = await this.fetchTaxonInfo(taxonKey); + + if (!this.isValidTaxonRank(taxonInfo.rank)) { + return; + } + + taxonsData[taxonKey] = this.createTaxonObject( + taxonKey, + taxonInfo, + nbObservations + ); + } catch (error) { + console.error( + `Erreur lors de la récupération du taxon ${taxonKey}:`, + error + ); + } + } + + /** + * Creates a standardized Taxon object from retrieved information. + * + * @param taxonKey - Unique identifier of the taxon in GBIF + * @param taxonInfo - Taxon information (scientific name, vernacular name, rank) + * @param nbObservations - Number of observations for this taxon + * @returns Formatted Taxon object + */ + private createTaxonObject( + taxonKey: string, + taxonInfo: { + scientificName: string; + vernacularName: string; + rank: string; + }, + nbObservations: number + ): Taxon { + return { + acceptedScientificName: taxonInfo.scientificName, + vernacularName: taxonInfo.vernacularName || '', + taxonId: taxonKey, + mediaUrl: NO_IMAGE_URL, + taxonRank: taxonInfo.rank, + kingdom: '', + class: '', + nbObservations: nbObservations, + description: '', + lastSeenDate: new Date(), + }; + } + + /** + * Processes dataset facets returned by the GBIF API. + * Extracts information from each dataset (UUID, number of observations). + * + * @param facets - List of facets returned by the API + * @param datasetData - Object to store dataset data + */ + private processDatasetFacets( + facets: any[], + datasetData: Record + ): void { + const datasetFacet = facets.find((f: any) => f.field === 'DATASET_KEY'); + + if (!datasetFacet?.counts) { + return; + } + + datasetFacet.counts.forEach((facetItem: any) => { + datasetData[facetItem.name] = { + uuid: facetItem.name, + name: facetItem.name, + nbObservations: facetItem.count, + }; + }); + } + + /** + * Checks if a taxonomic rank is specific enough. + * Filters out ranks that are too high like family, order, class, etc. + * + * @param rank - Taxonomic rank to validate (e.g., 'SPECIES', 'GENUS', 'FAMILY') + * @returns true if the rank is accepted, false otherwise + */ + private isValidTaxonRank(rank: string): boolean { + const acceptedRanks = ['SPECIES', 'SUBSPECIES']; + return acceptedRanks.includes(rank.toUpperCase()); + } + + fetchTaxonInfo(idTaxon: string): Promise<{ + scientificName: string; + vernacularName: string; + rank: string; + taxonKey: number; + }> { + const url = `${this.GBIF_ENDPOINT}/species/${idTaxon}`; + return fetch(url) + .then((response) => response.json()) + .then((json) => ({ + scientificName: json.scientificName, + vernacularName: json.vernacularName, + rank: json.rank, + taxonKey: json.key, + })); + } + + fetchTaxonStatus(idTaxon: string): Promise<{ + iucnRedListCategory: string; + code: string; + }> { + const url = `${this.GBIF_ENDPOINT}/species/${idTaxon}/iucnRedListCategory`; + return fetch(url) + .then((response) => response.json()) + .then((json) => ({ + iucnRedListCategory: json.category, + code: json.code, + })); + } + + searchTaxon( + searchString: string = '', + params: OccurrenceParams = {} + ): Promise> { + const url = `${this.GBIF_ENDPOINT}/species/search?q=${searchString}&limit=20`; + return fetch(url) + .then((response) => response.json()) + .then((json) => + json.results.map((element: any) => ({ + scientificName: element.scientificName, + taxonKey: element.key, + })) + ); + } + + getTaxonDetailPage(taxonId: string): string { + return `https://www.gbif.org/species/${taxonId}`; + } + + sourceDetailMessage(): string { + return useI18n().t('source.gbifWarning', { + nbObs: GBIF_DEFAULT_TAXON_LIMIT, + }); + } + getSourceUrl(): string | null { + return 'https://www.gbif.org'; + } + + getDatasetUrl(datasetID: any): string | null { + return `${this.getSourceUrl()}/dataset/${datasetID}`; + } + + searchOnAPI(searchString: string): Promise { + return fetch( + `${this.GBIF_ENDPOINT}/species/search?q=${searchString}&status=ACCEPTED&qField=VERNACULAR&limit=100` + ) + .then((response) => response.json()) + .then((json) => json.results); + } +} diff --git a/src/lib/connectors/utils.ts b/src/lib/connectors/utils.ts index 4c5c31c4..82303ce9 100644 --- a/src/lib/connectors/utils.ts +++ b/src/lib/connectors/utils.ts @@ -1,6 +1,7 @@ import { CONNECTORS } from './connectors'; import { GbifConnector } from './gbif'; import { GeoNatureConnector } from './geonature'; +import { GbifFacetConnector } from './gbiffacet'; import { Connector } from './connector'; import { simplify, truncate } from '@turf/turf'; import { Feature, Polygon, MultiPolygon } from 'geojson'; @@ -17,8 +18,10 @@ export function getConnector( return new GbifConnector(params); case CONNECTORS.GeoNature: return new GeoNatureConnector(params); + case CONNECTORS.GBIF_FACET: + return new GbifFacetConnector(params); default: - return new GbifConnector(params); + return new GbifFacetConnector(params); } } From 3dac7d968e7f78d4e2f9a272a459ad1f4d00609d Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Wed, 10 Dec 2025 17:47:03 +0100 Subject: [PATCH 02/10] fix test frontend --- tests/configurator.spec.ts | 4 +--- tests/list.spec.ts | 2 +- tests/maplist.spec.ts | 4 +--- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/configurator.spec.ts b/tests/configurator.spec.ts index 18990480..9ed2c08c 100644 --- a/tests/configurator.spec.ts +++ b/tests/configurator.spec.ts @@ -21,9 +21,7 @@ async function checkForParameterChange(page, key, value) { test.describe('Form parameters testing', () => { test.beforeEach(async ({ page }) => { - await page.goto( - '/#/config?widgetType=mapList&lang=fr&GBIF_ENDPOINT=https://api.gbif-uat.org/v1/' - ); + await page.goto('/#/config?widgetType=mapList&lang=fr'); }); test('enable/disable filter', async ({ page }) => { diff --git a/tests/list.spec.ts b/tests/list.spec.ts index ea3d5dc2..27008447 100644 --- a/tests/list.spec.ts +++ b/tests/list.spec.ts @@ -5,7 +5,7 @@ import { TaxonList } from './page/taxonlistcontroller'; test.describe('Simple list', () => { test.beforeEach(async ({ page }) => { await page.goto( - 'http://localhost:5173/#/?widgetType=list&x=6.076641082763673&y=44.55164823782743&GBIF_ENDPOINT=https://api.gbif-uat.org/v1/&showFilters=true&lang=en&switchModeAvailable=true' + 'http://localhost:5173/#/?widgetType=list&x=6.076641082763673&y=44.55164823782743&showFilters=true&lang=en&switchModeAvailable=true' ); }); test('sort', async ({ page }) => { diff --git a/tests/maplist.spec.ts b/tests/maplist.spec.ts index cbb626ba..98197313 100644 --- a/tests/maplist.spec.ts +++ b/tests/maplist.spec.ts @@ -4,9 +4,7 @@ import { TaxonList } from './page/taxonlistcontroller'; test.describe('Map list', () => { test.beforeEach(async ({ page }) => { - await page.goto( - 'http://localhost:5173/#/?GBIF_ENDPOINT=https://api.gbif-uat.org/v1/' - ); + await page.goto('http://localhost:5173/#/'); }); test('should load map list', async ({ page }) => { await page.locator('.mapC').isVisible(); From c1f535f85e554237241644bc41a113d1004b4826 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Wed, 10 Dec 2025 17:50:05 +0100 Subject: [PATCH 03/10] remove date from the facet connector --- src/lib/connectors/gbiffacet.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/connectors/gbiffacet.ts b/src/lib/connectors/gbiffacet.ts index c6b1e587..67fe8245 100644 --- a/src/lib/connectors/gbiffacet.ts +++ b/src/lib/connectors/gbiffacet.ts @@ -266,7 +266,7 @@ export class GbifFacetConnector extends Connector { class: '', nbObservations: nbObservations, description: '', - lastSeenDate: new Date(), + // lastSeenDate: new Date(), }; } From 2906b2153292e2907d154cd3f262ff6316a8947e Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Wed, 10 Dec 2025 18:20:44 +0100 Subject: [PATCH 04/10] add translation --- src/lib/connectors/gbiffacet.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/connectors/gbiffacet.ts b/src/lib/connectors/gbiffacet.ts index 67fe8245..41211fa5 100644 --- a/src/lib/connectors/gbiffacet.ts +++ b/src/lib/connectors/gbiffacet.ts @@ -359,7 +359,7 @@ export class GbifFacetConnector extends Connector { sourceDetailMessage(): string { return useI18n().t('source.gbifWarning', { - nbObs: GBIF_DEFAULT_TAXON_LIMIT, + nbTaxons: GBIF_DEFAULT_TAXON_LIMIT, }); } getSourceUrl(): string | null { From fbd25b60061c03fedef1ecc391d6dc440cd622ae Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Wed, 10 Dec 2025 18:21:02 +0100 Subject: [PATCH 05/10] limit taxoninfo fetching --- src/lib/connectors/gbiffacet.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/lib/connectors/gbiffacet.ts b/src/lib/connectors/gbiffacet.ts index 41211fa5..9d0ef8f2 100644 --- a/src/lib/connectors/gbiffacet.ts +++ b/src/lib/connectors/gbiffacet.ts @@ -197,11 +197,15 @@ export class GbifFacetConnector extends Connector { return; } - const taxonPromises = taxonFacet.counts.map((facetItem: any) => - this.processSingleTaxon(facetItem, taxonsData) - ); + const BATCH_SIZE = 50; // Process 10 taxa at a time - await Promise.all(taxonPromises); // TODO : watch out for performance + for (let i = 0; i < taxonFacet.counts.length; i += BATCH_SIZE) { + const batch = taxonFacet.counts.slice(i, i + BATCH_SIZE); + const batchPromises = batch.map((facetItem: any) => + this.processSingleTaxon(facetItem, taxonsData) + ); + await Promise.all(batchPromises); + } } /** From ccdd82eb7d7efd4af5a80f852678dec50af65975 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Sun, 11 Jan 2026 13:10:49 +0100 Subject: [PATCH 06/10] Reduce the API calls to three ! One for the GBIF Facet, the second to fetch vernacularName, scientificName from Wikidata and the last one to fetch taxonomic info from iNaturalist API --- src/components/core/taxonList/TaxonView.vue | 8 +- src/lib/connectors/gbiffacet.ts | 549 ++++++++++---------- src/lib/models.ts | 4 + src/lib/utils.ts | 14 + 4 files changed, 305 insertions(+), 270 deletions(-) diff --git a/src/components/core/taxonList/TaxonView.vue b/src/components/core/taxonList/TaxonView.vue index 932886cd..f36ea80d 100644 --- a/src/components/core/taxonList/TaxonView.vue +++ b/src/components/core/taxonList/TaxonView.vue @@ -62,10 +62,10 @@ }); function refreshVernacularName() { + if (taxon.vernacularName) return; connector.value.fetchVernacularName(taxon.taxonId).then((name) => { if (name) { - vernacularName.value = name.split(',')[0]; - taxon.vernacularName = vernacularName.value; + taxon.vernacularName = name.split(',')[0]; } }); } @@ -94,7 +94,7 @@ v-if="mode == 'gallery'" :picture="mediaDisplayed" :audio="speciesAudio" - :vernacular-name="vernacularName || taxon.acceptedScientificName" + :vernacular-name="taxon.vernacularName || taxon.acceptedScientificName" :url-detail-page="fetchDetailUrl(taxon.taxonId)" :accepted-scientific-name="taxon.acceptedScientificName" :cols="props.cols" @@ -106,7 +106,7 @@ :picture="mediaDisplayed" :audio="speciesAudio" :accepted-scientific-name="taxon.acceptedScientificName" - :vernacular-name="vernacularName || taxon.acceptedScientificName" + :vernacular-name="taxon.vernacularName || taxon.acceptedScientificName" :url-detail-page="fetchDetailUrl(taxon.taxonId)" :nb-observations="taxon?.nbObservations" :last-seen-date="taxon?.lastSeenDate" diff --git a/src/lib/connectors/gbiffacet.ts b/src/lib/connectors/gbiffacet.ts index 9d0ef8f2..686f4087 100644 --- a/src/lib/connectors/gbiffacet.ts +++ b/src/lib/connectors/gbiffacet.ts @@ -7,44 +7,95 @@ import { getMediaSource, SOURCE_ } from '../media/media'; import { useI18n } from 'vue-i18n'; import { CONNECTORS } from './connectors'; import { GBIFSearchScoring } from './search/gbif'; +import { removeHoles } from './utils'; +import { parse, stringify } from 'wellknown'; +import { fetchJson } from '../utils'; const GBIF_ENDPOINT_DEFAULT = 'https://api.gbif.org/v1'; const GBIF_DEFAULT_TAXON_LIMIT = 1000; +const WIKIDATA_SPARQL_ENDPOINT = 'https://query.wikidata.org/sparql'; +const INAT_API_ENDPOINT = 'https://api.inaturalist.org/v1/taxa'; +const INAT_BATCH_SIZE = 30; type OccurrenceParams = Record; +interface TaxonWithAncestors extends Taxon { + ancestors?: Array<{ id: number; name: string; rank: string }>; + iconicTaxonName?: string; + iNatId?: string; +} + +interface WikidataBinding { + gbifID: { value: string }; + iNatID?: { value: string }; + scientificName?: { value: string }; + commonName?: { value: string }; // nouveau + image?: { value: string }; +} + +interface EnrichedTaxonData { + scientificName?: string; + commonName?: string; + photo?: string; + iNatId?: string; + ancestors?: any[]; + kingdom?: string; + phylum?: string; + order?: string; + family?: string; + genus?: string; + iconicTaxonName?: string; +} + +function buildUrl(base: string, params: Record): string { + const url = new URL(base); + Object.entries(params).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((v) => url.searchParams.append(key, v)); + } else if (value !== undefined && value !== null) { + url.searchParams.append(key, value); + } + }); + return url.toString(); +} + +function callOccurrenceApi(params: OccurrenceParams = {}): Promise { + return fetchJson( + buildUrl(`${GBIF_ENDPOINT_DEFAULT}/occurrence/search`, params) + ); +} + export class GbifFacetConnector extends Connector { name: string; GBIF_ENDPOINT: string; LIMIT: number; + NB_PAGES: number = 1; + private enrichTaxonomyCache: Map = new Map(); + + taxonClass2SourceID = { + Aves: 212, + Mammalia: 359, + Reptilia: 358, + Amphibia: 131, + Insecta: 216, + Arachnida: 367, + Gastropoda: 225, + Bivalvia: 137, + Magnoliopsida: 220, + Liliopsida: 196, + Pinopsida: 194, + }; constructor(options: ConnectorOptions) { super(options); this.name = CONNECTORS.GBIF_FACET; - - // specific parameters - this.GBIF_ENDPOINT = - this.options?.GBIF_ENDPOINT || GBIF_ENDPOINT_DEFAULT; - this.LIMIT = this.options?.LIMIT || GBIF_DEFAULT_TAXON_LIMIT; + this.GBIF_ENDPOINT = options?.GBIF_ENDPOINT || GBIF_ENDPOINT_DEFAULT; + this.LIMIT = options?.LIMIT || GBIF_DEFAULT_TAXON_LIMIT; this.referential = TAXON_REFERENTIAL.GBIF; - this.imageSource = this.imageSource || getMediaSource(SOURCE_.wikidata); this.soundSource = this.soundSource || getMediaSource(SOURCE_.wikidata); - this.taxonClass2SourceID = { - Aves: 212, - Mammalia: 359, - Reptilia: 358, - Amphibia: 131, - Insecta: 216, - Arachnida: 367, - Gastropoda: 225, - Bivalvia: 137, - Magnoliopsida: 220, - Liliopsida: 196, - Pinopsida: 194, - }; this.isSearchOnAPIAvailable = true; this.scoringSearchClass = new GBIFSearchScoring(); } @@ -56,7 +107,7 @@ export class GbifFacetConnector extends Connector { name: 'GBIF_ENDPOINT', label: "Adresse de l'API du GBIF", type: String, - default: 'https://api.gbif.org/v1', + default: GBIF_ENDPOINT_DEFAULT, }, { name: 'LIMIT', @@ -77,56 +128,147 @@ export class GbifFacetConnector extends Connector { de: 'deu', }; const currentLanguage = ParameterStore.getInstance().lang.value; - return fetch( + + return fetchJson( `${this.GBIF_ENDPOINT}/species/${taxonID}/vernacularNames?limit=100` - ) - .then((response) => response.json()) - .then((data) => { - const nameData = data.results.find( - (nameData: any) => - nameData.language === mapping_language[currentLanguage] - ); - return nameData - ? nameData.vernacularName.charAt(0).toUpperCase() + - nameData.vernacularName.slice(1) - : undefined; - }); + ).then((data) => { + const match = data.results.find( + (r: any) => r.language === mapping_language[currentLanguage] + ); + return match + ? match.vernacularName.charAt(0).toUpperCase() + + match.vernacularName.slice(1) + : undefined; + }); } - fetchOccurrence(params: any = {}): Promise { - super.fetchOccurrence(params); + private buildWikidataSparql(gbifIds: string[], lang: string): string { + const values = gbifIds.map((id) => `"${id}"`).join(' '); + return ` + SELECT ?gbifID ?iNatID ?scientificName ?commonName ?image + WHERE { + VALUES ?gbifID { ${values} } + ?taxon wdt:P846 ?gbifID . + OPTIONAL { ?taxon wdt:P3151 ?iNatID . } + OPTIONAL { ?taxon wdt:P225 ?scientificName . } + OPTIONAL { ?taxon p:P1843 ?commonNameStatement . + ?commonNameStatement ps:P1843 ?commonName . + FILTER(LANG(?commonName) = "${lang}") + } + OPTIONAL { ?taxon wdt:P18 ?image . } + } + `; + } - const searchParams = this.buildSearchParams(params); - const urlWithParams = this.buildSearchUrl(searchParams); + private async enrichTaxonomy( + gbifIds: string[] + ): Promise> { + const uncachedIds = gbifIds.filter( + (id) => !this.enrichTaxonomyCache.has(id) + ); + if (!uncachedIds.length) return this.enrichTaxonomyCache; - return fetch(urlWithParams.toString()) - .then((response) => response.json()) - .then(async (data) => { - const taxonsData: Record = {}; - const datasetData: Record = {}; + const currentLang = ParameterStore.getInstance().lang.value; - if (data.facets && data.facets.length > 0) { - await this.processTaxonFacets(data.facets, taxonsData); - this.processDatasetFacets(data.facets, datasetData); - } + try { + // Fetch all Wikidata IDs in a single POST + const sparql = this.buildWikidataSparql(uncachedIds, currentLang); + const wikidataResponse = await fetch(WIKIDATA_SPARQL_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/sparql-results+json', + }, + body: new URLSearchParams({ query: sparql }), + }); + if (!wikidataResponse.ok) + throw new Error( + `Wikidata POST failed: ${wikidataResponse.status}` + ); + const wikidataData = await wikidataResponse.json(); + + // Process Wikidata results + const gbifToINat: Record = {}; + wikidataData.results.bindings.forEach((b: WikidataBinding) => { + const gbifId = b.gbifID.value; + const existing = this.enrichTaxonomyCache.get(gbifId) || {}; + this.enrichTaxonomyCache.set(gbifId, { + ...existing, + scientificName: b.scientificName?.value, + commonName: b.commonName?.value.replace(/^./, (char) => + char.toUpperCase() + ), + photo: b.image?.value, + }); + if (b.iNatID) gbifToINat[gbifId] = b.iNatID.value; + }); - return { - taxons: Object.values(taxonsData), - datasets: Object.values(datasetData), - }; + // iNaturalist + const iNatIds = Object.values(gbifToINat); + const iNatPromises: Promise[] = []; + for (let i = 0; i < iNatIds.length; i += INAT_BATCH_SIZE) { + const batch = iNatIds.slice(i, i + INAT_BATCH_SIZE); + if (!batch.length) continue; + iNatPromises.push( + fetchJson(`${INAT_API_ENDPOINT}/${batch.join(',')}`) + ); + } + + const iNatResults = await Promise.all(iNatPromises); + + // Merge iNaturalist data into cache + iNatResults.forEach((iNatData) => { + Object.entries(gbifToINat).forEach(([gbifId, iNatId]) => { + const taxon = iNatData.results.find( + (t: any) => t.id == iNatId + ); + if (!taxon) return; + const existing = this.enrichTaxonomyCache.get(gbifId) || {}; + this.enrichTaxonomyCache.set(gbifId, { + ...existing, + iNatId, + scientificName: taxon.name, + commonName: + existing.commonName || taxon.preferred_common_name, + ancestors: taxon.ancestors || [], + kingdom: taxon.ancestors?.find( + (a: any) => a.rank === 'kingdom' + )?.name, + phylum: taxon.ancestors?.find( + (a: any) => a.rank === 'phylum' + )?.name, + order: taxon.ancestors?.find( + (a: any) => a.rank === 'order' + )?.name, + family: taxon.ancestors?.find( + (a: any) => a.rank === 'family' + )?.name, + genus: taxon.ancestors?.find( + (a: any) => a.rank === 'genus' + )?.name, + iconicTaxonName: taxon.iconic_taxon_name, // equivalent of the functional group in INPN + photo: + taxon.default_photo?.medium_url || existing.photo, + }); + }); }); + } catch (e) { + console.error(`[${this.name}] enrichTaxonomy failed`, e); + } + + return this.enrichTaxonomyCache; } - /** - * Builds search parameters for the GBIF API. - * Merges default parameters with user-provided ones. - * Handles date transformation and taxonomic class mapping. - * - * @param params - Custom search parameters - * @returns Formatted parameters for the GBIF API - */ - private buildSearchParams(params: any): OccurrenceParams { - let searchParams = { + /* ---------- fetchOccurrence ---------- */ + fetchOccurrence(params: any = {}): Promise { + super.fetchOccurrence(params); + if (params.geometry) + params.geometry = stringify(removeHoles(parse(params.geometry))); + + const defaultParams = { + facet: ['speciesKey', 'datasetKey'], + facetLimit: this.LIMIT, + limit: 0, occurrenceStatus: 'PRESENT', basisOfRecord: [ 'OBSERVATION', @@ -136,249 +278,124 @@ export class GbifFacetConnector extends Connector { ], hasGeospatialIssue: false, hasCoordinate: true, - facet: ['taxonKey', 'datasetKey'], - facetLimit: this.LIMIT, - limit: 0, ...params, }; - // Gestion de la plage de dates - if (searchParams.dateMin && searchParams.dateMax) { - searchParams.eventDate = `${searchParams.dateMin},${searchParams.dateMax}`; - delete searchParams.dateMin; - delete searchParams.dateMax; + if (defaultParams.dateMin && defaultParams.dateMax) { + defaultParams.eventDate = `${defaultParams.dateMin},${defaultParams.dateMax}`; + delete defaultParams.dateMin; + delete defaultParams.dateMax; } - // Gestion de la classe taxonomique if (params?.class) { - searchParams.classKey = this.taxonClass2SourceID[params.class]; - delete searchParams.class; - } - - return searchParams; - } - - /** - * Builds the complete URL for the GBIF search query. - * Properly encodes simple parameters and arrays. - * - * @param params - Search parameters to encode in the URL - * @returns Complete URL with all parameters - */ - private buildSearchUrl(params: OccurrenceParams): URL { - const url = new URL(`${this.GBIF_ENDPOINT}/occurrence/search`); - - Object.entries(params).forEach(([key, value]) => { - if (Array.isArray(value)) { - value.forEach((item) => url.searchParams.append(key, item)); - } else { - url.searchParams.append(key, value); - } - }); - - return url; - } - - /** - * Processes taxon facets returned by the GBIF API. - * Retrieves detailed information for each taxon in parallel. - * Filters taxa based on their taxonomic rank. - * - * @param facets - List of facets returned by the API - * @param taxonsData - Object to store validated taxon data - */ - private async processTaxonFacets( - facets: any[], - taxonsData: Record - ): Promise { - const taxonFacet = facets.find((f: any) => f.field === 'TAXON_KEY'); - - if (!taxonFacet?.counts) { - return; + defaultParams.classKey = this.taxonClass2SourceID[params?.class]; + delete defaultParams.class; } - const BATCH_SIZE = 50; // Process 10 taxa at a time - - for (let i = 0; i < taxonFacet.counts.length; i += BATCH_SIZE) { - const batch = taxonFacet.counts.slice(i, i + BATCH_SIZE); - const batchPromises = batch.map((facetItem: any) => - this.processSingleTaxon(facetItem, taxonsData) - ); - await Promise.all(batchPromises); - } - } - - /** - * Processes an individual taxon from the facet. - * Retrieves its information from the GBIF API and validates its taxonomic rank. - * If the rank is valid, adds the taxon to the collection. - * - * @param facetItem - Facet item containing the taxonKey and count - * @param taxonsData - Collection to add the taxon to if valid - */ - private async processSingleTaxon( - facetItem: any, - taxonsData: Record - ): Promise { - const taxonKey = facetItem.name; - const nbObservations = facetItem.count; + return callOccurrenceApi(defaultParams).then(async (gbifData) => { + if (!gbifData.facets || gbifData.facets.length === 0) + return { taxons: [], datasets: [] }; - try { - const taxonInfo = await this.fetchTaxonInfo(taxonKey); - - if (!this.isValidTaxonRank(taxonInfo.rank)) { - return; - } - - taxonsData[taxonKey] = this.createTaxonObject( - taxonKey, - taxonInfo, - nbObservations + const taxonFacet = gbifData.facets.find( + (f: any) => f.field === 'SPECIES_KEY' ); - } catch (error) { - console.error( - `Erreur lors de la récupération du taxon ${taxonKey}:`, - error + const datasetFacet = gbifData.facets.find( + (f: any) => f.field === 'DATASET_KEY' ); - } - } - - /** - * Creates a standardized Taxon object from retrieved information. - * - * @param taxonKey - Unique identifier of the taxon in GBIF - * @param taxonInfo - Taxon information (scientific name, vernacular name, rank) - * @param nbObservations - Number of observations for this taxon - * @returns Formatted Taxon object - */ - private createTaxonObject( - taxonKey: string, - taxonInfo: { - scientificName: string; - vernacularName: string; - rank: string; - }, - nbObservations: number - ): Taxon { - return { - acceptedScientificName: taxonInfo.scientificName, - vernacularName: taxonInfo.vernacularName || '', - taxonId: taxonKey, - mediaUrl: NO_IMAGE_URL, - taxonRank: taxonInfo.rank, - kingdom: '', - class: '', - nbObservations: nbObservations, - description: '', - // lastSeenDate: new Date(), - }; - } - /** - * Processes dataset facets returned by the GBIF API. - * Extracts information from each dataset (UUID, number of observations). - * - * @param facets - List of facets returned by the API - * @param datasetData - Object to store dataset data - */ - private processDatasetFacets( - facets: any[], - datasetData: Record - ): void { - const datasetFacet = facets.find((f: any) => f.field === 'DATASET_KEY'); - - if (!datasetFacet?.counts) { - return; - } + const datasets: Dataset[] = + datasetFacet?.counts.map((facet: any) => ({ + uuid: facet.name, + name: facet.name, + nbObservations: facet.count, + })) || []; + + if (!taxonFacet || !taxonFacet.counts) + return { taxons: [], datasets }; + + const speciesIds = taxonFacet.counts.map((f: any) => f.name); + const enrichmentMap = await this.enrichTaxonomy(speciesIds); + + const taxons: TaxonWithAncestors[] = taxonFacet.counts.map( + (facet: any) => { + const gbifId = facet.name; + const enrichment = enrichmentMap.get(gbifId); + + const taxon: Taxon = { + taxonId: gbifId, + acceptedScientificName: + enrichment?.scientificName || '', + vernacularName: enrichment?.commonName || '', + taxonRank: 'SPECIES', + nbObservations: facet.count, + mediaUrl: enrichment?.photo || NO_IMAGE_URL, + description: '', + lastSeenDate: new Date(), + kingdom: enrichment?.kingdom || '', + class: enrichment?.iconicTaxonName || '', + }; + + return taxon; + } + ); - datasetFacet.counts.forEach((facetItem: any) => { - datasetData[facetItem.name] = { - uuid: facetItem.name, - name: facetItem.name, - nbObservations: facetItem.count, - }; + return { taxons, datasets }; }); } - /** - * Checks if a taxonomic rank is specific enough. - * Filters out ranks that are too high like family, order, class, etc. - * - * @param rank - Taxonomic rank to validate (e.g., 'SPECIES', 'GENUS', 'FAMILY') - * @returns true if the rank is accepted, false otherwise - */ - private isValidTaxonRank(rank: string): boolean { - const acceptedRanks = ['SPECIES', 'SUBSPECIES']; - return acceptedRanks.includes(rank.toUpperCase()); - } - - fetchTaxonInfo(idTaxon: string): Promise<{ - scientificName: string; - vernacularName: string; - rank: string; - taxonKey: number; - }> { - const url = `${this.GBIF_ENDPOINT}/species/${idTaxon}`; - return fetch(url) - .then((response) => response.json()) - .then((json) => ({ + fetchTaxonInfo(idTaxon: string) { + return fetchJson(`${this.GBIF_ENDPOINT}/species/${idTaxon}`).then( + (json) => ({ scientificName: json.scientificName, vernacularName: json.vernacularName, rank: json.rank, taxonKey: json.key, - })); + }) + ); } - fetchTaxonStatus(idTaxon: string): Promise<{ - iucnRedListCategory: string; - code: string; - }> { - const url = `${this.GBIF_ENDPOINT}/species/${idTaxon}/iucnRedListCategory`; - return fetch(url) - .then((response) => response.json()) - .then((json) => ({ - iucnRedListCategory: json.category, - code: json.code, - })); + fetchTaxonStatus(idTaxon: string) { + return fetchJson<{ category: string; code: string }>( + `${this.GBIF_ENDPOINT}/species/${idTaxon}/iucnRedListCategory` + ).then((json) => ({ + iucnRedListCategory: json.category, + code: json.code, + })); } - searchTaxon( - searchString: string = '', - params: OccurrenceParams = {} - ): Promise> { - const url = `${this.GBIF_ENDPOINT}/species/search?q=${searchString}&limit=20`; - return fetch(url) - .then((response) => response.json()) - .then((json) => - json.results.map((element: any) => ({ - scientificName: element.scientificName, - taxonKey: element.key, - })) - ); + searchTaxon(searchString: string = '', params: OccurrenceParams = {}) { + return fetchJson( + `${this.GBIF_ENDPOINT}/species/search?q=${searchString}&limit=20` + ).then((json) => + json.results.map((e: any) => ({ + scientificName: e.scientificName, + taxonKey: e.key, + })) + ); } - getTaxonDetailPage(taxonId: string): string { + getTaxonDetailPage(taxonId: string) { return `https://www.gbif.org/species/${taxonId}`; } - sourceDetailMessage(): string { + sourceDetailMessage() { return useI18n().t('source.gbifWarning', { - nbTaxons: GBIF_DEFAULT_TAXON_LIMIT, + nbObs: this.LIMIT * this.NB_PAGES, }); } - getSourceUrl(): string | null { + + getSourceUrl() { return 'https://www.gbif.org'; } - - getDatasetUrl(datasetID: any): string | null { + getDatasetUrl(datasetID: any) { return `${this.getSourceUrl()}/dataset/${datasetID}`; } - searchOnAPI(searchString: string): Promise { - return fetch( + searchOnAPI(searchString: string) { + return fetchJson( `${this.GBIF_ENDPOINT}/species/search?q=${searchString}&status=ACCEPTED&qField=VERNACULAR&limit=100` - ) - .then((response) => response.json()) - .then((json) => json.results); + ).then((json) => json.results); } } + +export default GbifFacetConnector; diff --git a/src/lib/models.ts b/src/lib/models.ts index 611e500c..0c17be94 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -10,6 +10,10 @@ export interface Taxon { lastSeenDate?: Date | null; kingdom?: string; class?: string; + phylum?: string; + order?: string; + family?: string; + genus?: string; } export enum MediaType { sound = 'sound', diff --git a/src/lib/utils.ts b/src/lib/utils.ts index f6fc7c69..68f4dbcb 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -96,6 +96,19 @@ function isRunningOnMobile() { return check; } +/** + * Fetches JSON data from a given URL and returns it as a promise. + * If the HTTP response is not OK, it throws an error with the HTTP status code. + * @template T The type of the JSON data to be fetched. + * @param {string} url The URL to fetch the JSON data from. + * @returns {Promise} A promise resolving to the fetched JSON data. + */ +async function fetchJson(url: string): Promise { + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); +} + export { toWKT, restoreMapState, @@ -103,4 +116,5 @@ export { getBaseUrl, validURL, isRunningOnMobile, + fetchJson, }; From 8b978c7d04f35fa315dd2d7adbaef230b19b19a1 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Tue, 26 May 2026 12:36:17 +0200 Subject: [PATCH 07/10] remove inaturalist api calls+ remove duplicated api calls to fetch images on wikidata --- src/components/core/taxonList/TaxonView.vue | 14 +- src/lib/connectors/gbiffacet.ts | 140 +++++++++++--------- src/lib/media/Wikidata.ts | 2 +- 3 files changed, 88 insertions(+), 68 deletions(-) diff --git a/src/components/core/taxonList/TaxonView.vue b/src/components/core/taxonList/TaxonView.vue index f36ea80d..787a3ea6 100644 --- a/src/components/core/taxonList/TaxonView.vue +++ b/src/components/core/taxonList/TaxonView.vue @@ -34,7 +34,14 @@ function fetchTaxonImage() { speciesPhoto.value = []; - if (taxon.taxonId) { + if (taxon.mediaUrl) { + speciesPhoto.value = [ + { + url: taxon.mediaUrl, + typeMedia: MediaType.image, + }, + ]; + } else if (taxon.taxonId) { connector.value.imageSource .fetchPicture(taxon.taxonId, connector.value) .then((response) => { @@ -62,7 +69,10 @@ }); function refreshVernacularName() { - if (taxon.vernacularName) return; + if (taxon.vernacularName) { + console.log('Vernacular name already exists, no need to fetch it'); + return; + } connector.value.fetchVernacularName(taxon.taxonId).then((name) => { if (name) { taxon.vernacularName = name.split(',')[0]; diff --git a/src/lib/connectors/gbiffacet.ts b/src/lib/connectors/gbiffacet.ts index 686f4087..58a9dd33 100644 --- a/src/lib/connectors/gbiffacet.ts +++ b/src/lib/connectors/gbiffacet.ts @@ -14,8 +14,6 @@ import { fetchJson } from '../utils'; const GBIF_ENDPOINT_DEFAULT = 'https://api.gbif.org/v1'; const GBIF_DEFAULT_TAXON_LIMIT = 1000; const WIKIDATA_SPARQL_ENDPOINT = 'https://query.wikidata.org/sparql'; -const INAT_API_ENDPOINT = 'https://api.inaturalist.org/v1/taxa'; -const INAT_BATCH_SIZE = 30; type OccurrenceParams = Record; @@ -29,8 +27,14 @@ interface WikidataBinding { gbifID: { value: string }; iNatID?: { value: string }; scientificName?: { value: string }; - commonName?: { value: string }; // nouveau + commonName?: { value: string }; image?: { value: string }; + kingdomLabel?: { value: string }; + phylumLabel?: { value: string }; + classLabel?: { value: string }; + orderLabel?: { value: string }; + familyLabel?: { value: string }; + genusLabel?: { value: string }; } interface EnrichedTaxonData { @@ -146,6 +150,7 @@ export class GbifFacetConnector extends Connector { const values = gbifIds.map((id) => `"${id}"`).join(' '); return ` SELECT ?gbifID ?iNatID ?scientificName ?commonName ?image + ?kingdomLabel ?phylumLabel ?classLabel ?orderLabel ?familyLabel ?genusLabel WHERE { VALUES ?gbifID { ${values} } ?taxon wdt:P846 ?gbifID . @@ -156,6 +161,38 @@ export class GbifFacetConnector extends Connector { FILTER(LANG(?commonName) = "${lang}") } OPTIONAL { ?taxon wdt:P18 ?image . } + + # Walk the taxonomy tree to get parent taxon ranks - use scientific names directly + OPTIONAL { + ?taxon wdt:P171* ?kingdom . + ?kingdom wdt:P105 wd:Q36732 . + ?kingdom wdt:P225 ?kingdomLabel . + } + OPTIONAL { + ?taxon wdt:P171* ?phylum . + ?phylum wdt:P105 wd:Q38348 . + ?phylum wdt:P225 ?phylumLabel . + } + OPTIONAL { + ?taxon wdt:P171* ?class . + ?class wdt:P105 wd:Q37517 . + ?class wdt:P225 ?classLabel . + } + OPTIONAL { + ?taxon wdt:P171* ?order . + ?order wdt:P105 wd:Q36602 . + ?order wdt:P225 ?orderLabel . + } + OPTIONAL { + ?taxon wdt:P171* ?family . + ?family wdt:P105 wd:Q35409 . + ?family wdt:P225 ?familyLabel . + } + OPTIONAL { + ?taxon wdt:P171* ?genus . + ?genus wdt:P105 wd:Q34740 . + ?genus wdt:P225 ?genusLabel . + } } `; } @@ -167,11 +204,10 @@ export class GbifFacetConnector extends Connector { (id) => !this.enrichTaxonomyCache.has(id) ); if (!uncachedIds.length) return this.enrichTaxonomyCache; - const currentLang = ParameterStore.getInstance().lang.value; try { - // Fetch all Wikidata IDs in a single POST + // Fetch all Wikidata data in a single POST (no URL length limit) const sparql = this.buildWikidataSparql(uncachedIds, currentLang); const wikidataResponse = await fetch(WIKIDATA_SPARQL_ENDPOINT, { method: 'POST', @@ -187,70 +223,37 @@ export class GbifFacetConnector extends Connector { ); const wikidataData = await wikidataResponse.json(); - // Process Wikidata results - const gbifToINat: Record = {}; + // Process Wikidata results - now includes parent taxon ranks wikidataData.results.bindings.forEach((b: WikidataBinding) => { const gbifId = b.gbifID.value; - const existing = this.enrichTaxonomyCache.get(gbifId) || {}; - this.enrichTaxonomyCache.set(gbifId, { - ...existing, + + // Transform Wikidata image URL to use Wikimedia Commons thumbnail + let photoUrl = b.image?.value; + if (photoUrl) { + const fileName = photoUrl.split('/').pop(); + if (fileName) { + photoUrl = `https://commons.wikimedia.org/w/thumb.php?width=400&f=${fileName}`; + } + } + + const enrichedData: EnrichedTaxonData = { scientificName: b.scientificName?.value, - commonName: b.commonName?.value.replace(/^./, (char) => + commonName: b.commonName?.value?.replace(/^./, (char) => char.toUpperCase() ), - photo: b.image?.value, - }); - if (b.iNatID) gbifToINat[gbifId] = b.iNatID.value; - }); - - // iNaturalist - const iNatIds = Object.values(gbifToINat); - const iNatPromises: Promise[] = []; - for (let i = 0; i < iNatIds.length; i += INAT_BATCH_SIZE) { - const batch = iNatIds.slice(i, i + INAT_BATCH_SIZE); - if (!batch.length) continue; - iNatPromises.push( - fetchJson(`${INAT_API_ENDPOINT}/${batch.join(',')}`) - ); - } - - const iNatResults = await Promise.all(iNatPromises); - - // Merge iNaturalist data into cache - iNatResults.forEach((iNatData) => { - Object.entries(gbifToINat).forEach(([gbifId, iNatId]) => { - const taxon = iNatData.results.find( - (t: any) => t.id == iNatId - ); - if (!taxon) return; - const existing = this.enrichTaxonomyCache.get(gbifId) || {}; - this.enrichTaxonomyCache.set(gbifId, { - ...existing, - iNatId, - scientificName: taxon.name, - commonName: - existing.commonName || taxon.preferred_common_name, - ancestors: taxon.ancestors || [], - kingdom: taxon.ancestors?.find( - (a: any) => a.rank === 'kingdom' - )?.name, - phylum: taxon.ancestors?.find( - (a: any) => a.rank === 'phylum' - )?.name, - order: taxon.ancestors?.find( - (a: any) => a.rank === 'order' - )?.name, - family: taxon.ancestors?.find( - (a: any) => a.rank === 'family' - )?.name, - genus: taxon.ancestors?.find( - (a: any) => a.rank === 'genus' - )?.name, - iconicTaxonName: taxon.iconic_taxon_name, // equivalent of the functional group in INPN - photo: - taxon.default_photo?.medium_url || existing.photo, - }); - }); + photo: photoUrl, + iNatId: b.iNatID?.value, + // Parent taxon ranks from Wikidata - eliminates need for iNaturalist + kingdom: b.kingdomLabel?.value, + phylum: b.phylumLabel?.value, + order: b.orderLabel?.value, + family: b.familyLabel?.value, + genus: b.genusLabel?.value, + // Use class as iconicTaxonName for backward compatibility + iconicTaxonName: b.classLabel?.value, + }; + + this.enrichTaxonomyCache.set(gbifId, enrichedData); }); } catch (e) { console.error(`[${this.name}] enrichTaxonomy failed`, e); @@ -313,8 +316,15 @@ export class GbifFacetConnector extends Connector { if (!taxonFacet || !taxonFacet.counts) return { taxons: [], datasets }; + // Parallel prefetch: Start Wikidata enrichment immediately, don't await yet const speciesIds = taxonFacet.counts.map((f: any) => f.name); - const enrichmentMap = await this.enrichTaxonomy(speciesIds); + const enrichmentPromise = this.enrichTaxonomy(speciesIds); + + // Do any other synchronous work here while Wikidata fetches in parallel + // (In this case, datasets are already processed above) + + // Now await the enrichment data + const enrichmentMap = await enrichmentPromise; const taxons: TaxonWithAncestors[] = taxonFacet.counts.map( (facet: any) => { diff --git a/src/lib/media/Wikidata.ts b/src/lib/media/Wikidata.ts index f6246a03..84960ec4 100644 --- a/src/lib/media/Wikidata.ts +++ b/src/lib/media/Wikidata.ts @@ -77,7 +77,7 @@ function fetchCommonsMedia( { url: typeMedia === MediaType.image - ? `https://commons.wikimedia.org/w/thumb.php?width=700&f=${fileName}` + ? `https://commons.wikimedia.org/w/thumb.php?width=400&f=${fileName}` : info.url, source: `${ credit.artist From 2688344c66ba251baa7f78ea9276e114e9a07875 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Tue, 26 May 2026 13:06:39 +0200 Subject: [PATCH 08/10] refactor: update media handling in Taxon components and models for improved compatibility --- .../core/taxonList/TaxonDetailed.vue | 29 ++-- src/components/core/taxonList/TaxonView.vue | 51 +++++- src/lib/connectors/connector.ts | 5 + src/lib/connectors/gbiffacet.ts | 39 +++-- src/lib/media/MediaSource.ts | 11 ++ src/lib/media/Wikidata.ts | 160 ++++++++++++------ src/lib/models.ts | 3 +- 7 files changed, 221 insertions(+), 77 deletions(-) diff --git a/src/components/core/taxonList/TaxonDetailed.vue b/src/components/core/taxonList/TaxonDetailed.vue index 422b8e7a..7700ffc1 100644 --- a/src/components/core/taxonList/TaxonDetailed.vue +++ b/src/components/core/taxonList/TaxonDetailed.vue @@ -23,10 +23,10 @@ :media="props.picture" :alt="props.picture?.urlSource" > -
:not(.audio-overlay):not(.copyright-overlay) { width: 100%; - height: auto; - aspect-ratio: 1; - object-fit: cover; + } + + :deep(.image-wrapper) { + border-radius: 10px 10px 0px 0px; + -webkit-border-radius: 10px 10px 0px 0px; + -moz-border-radius: 10px 10px 0px 0px; + } + + :deep(.image-wrapper img) { border-radius: 10px 10px 0px 0px; -webkit-border-radius: 10px 10px 0px 0px; -moz-border-radius: 10px 10px 0px 0px; - margin: 0 auto; } .statistics-wrapper { diff --git a/src/components/core/taxonList/TaxonView.vue b/src/components/core/taxonList/TaxonView.vue index 787a3ea6..b17d1534 100644 --- a/src/components/core/taxonList/TaxonView.vue +++ b/src/components/core/taxonList/TaxonView.vue @@ -34,6 +34,40 @@ function fetchTaxonImage() { speciesPhoto.value = []; + + // First priority: use medias array if available + if (taxon.medias && taxon.medias.length > 0) { + const images = taxon.medias.filter( + (m) => m.typeMedia === MediaType.image + ); + if (images.length > 0) { + speciesPhoto.value = [...images]; + + // For detailed view, fetch full credits if not already present + images.forEach((media, index) => { + // Check if media already has full credits (has license field) + if (!media.license && media.source) { + // Use the media source's getCredits method to fetch full credits + connector.value.imageSource + .getCredits(media) + .then((enrichedMedia) => { + // Update the media with full credits + speciesPhoto.value[index] = enrichedMedia; + }) + .catch((err) => { + console.warn( + `Failed to fetch credits for ${media.source}:`, + err + ); + }); + } + }); + + return; + } + } + + // Second priority: use mediaUrl for backward compatibility if (taxon.mediaUrl) { speciesPhoto.value = [ { @@ -41,7 +75,9 @@ typeMedia: MediaType.image, }, ]; - } else if (taxon.taxonId) { + } + // Last resort: fetch from media source + else if (taxon.taxonId) { connector.value.imageSource .fetchPicture(taxon.taxonId, connector.value) .then((response) => { @@ -51,6 +87,19 @@ } function fetchTaxonAudio() { speciesAudio.value = null; + + // First priority: use medias array if available (includes full credits) + if (taxon.medias && taxon.medias.length > 0) { + const sounds = taxon.medias.filter( + (m) => m.typeMedia === MediaType.sound + ); + if (sounds.length > 0) { + speciesAudio.value = sounds[0]; + return; + } + } + + // Fallback: fetch from media source if (taxon.taxonId) { connector.value.soundSource .fetchSound(taxon.taxonId, connector.value) diff --git a/src/lib/connectors/connector.ts b/src/lib/connectors/connector.ts index 3569db39..b43e2c6b 100644 --- a/src/lib/connectors/connector.ts +++ b/src/lib/connectors/connector.ts @@ -265,6 +265,11 @@ export class Connector { return this.name; } + /** + * Returns the color associated with a given IUCN conservation status code. + * @param {IUCNCodeStatus} status - The IUCN conservation status code. + * @returns {string} The color associated with the given IUCN conservation status code. + */ getStatusColor(status: IUCNCodeStatus): string { const IUCNStatusColorDICT = { NA: '#C1B5A5', diff --git a/src/lib/connectors/gbiffacet.ts b/src/lib/connectors/gbiffacet.ts index 58a9dd33..657cf275 100644 --- a/src/lib/connectors/gbiffacet.ts +++ b/src/lib/connectors/gbiffacet.ts @@ -1,5 +1,5 @@ import { Connector, ConnectorOptions } from './connector'; -import { Dataset, SearchResult, Taxon } from '../models'; +import { Dataset, SearchResult, Taxon, Media, MediaType } from '../models'; import ParameterStore from '../parameterStore'; import { NO_IMAGE_URL } from '@/assets/constant'; import { TAXON_REFERENTIAL } from '../taxonReferential'; @@ -40,7 +40,7 @@ interface WikidataBinding { interface EnrichedTaxonData { scientificName?: string; commonName?: string; - photo?: string; + medias?: Media[]; iNatId?: string; ancestors?: any[]; kingdom?: string; @@ -224,15 +224,25 @@ export class GbifFacetConnector extends Connector { const wikidataData = await wikidataResponse.json(); // Process Wikidata results - now includes parent taxon ranks + // Store basic media info (filename) without fetching credits + // Credits will be fetched on-demand when detailed view is rendered wikidataData.results.bindings.forEach((b: WikidataBinding) => { const gbifId = b.gbifID.value; - - // Transform Wikidata image URL to use Wikimedia Commons thumbnail - let photoUrl = b.image?.value; - if (photoUrl) { - const fileName = photoUrl.split('/').pop(); - if (fileName) { - photoUrl = `https://commons.wikimedia.org/w/thumb.php?width=400&f=${fileName}`; + let medias: Media[] = []; + + // Store basic media with thumbnail URL and filename for later credit fetching + if (b.image?.value) { + const encodedFileName = b.image.value.split('/').pop(); + if (encodedFileName) { + // Decode the filename from the Wikidata URL + const fileName = decodeURIComponent(encodedFileName); + medias = [ + { + url: `https://commons.wikimedia.org/w/thumb.php?width=400&f=${encodeURIComponent(fileName)}`, + typeMedia: MediaType.image, + source: fileName, // Store decoded filename for later credit fetching + }, + ]; } } @@ -241,7 +251,7 @@ export class GbifFacetConnector extends Connector { commonName: b.commonName?.value?.replace(/^./, (char) => char.toUpperCase() ), - photo: photoUrl, + medias, iNatId: b.iNatID?.value, // Parent taxon ranks from Wikidata - eliminates need for iNaturalist kingdom: b.kingdomLabel?.value, @@ -331,6 +341,12 @@ export class GbifFacetConnector extends Connector { const gbifId = facet.name; const enrichment = enrichmentMap.get(gbifId); + // For backward compatibility, set mediaUrl to first image or NO_IMAGE_URL + const firstImageUrl = + enrichment?.medias?.find( + (m) => m.typeMedia === MediaType.image + )?.url || NO_IMAGE_URL; + const taxon: Taxon = { taxonId: gbifId, acceptedScientificName: @@ -338,7 +354,8 @@ export class GbifFacetConnector extends Connector { vernacularName: enrichment?.commonName || '', taxonRank: 'SPECIES', nbObservations: facet.count, - mediaUrl: enrichment?.photo || NO_IMAGE_URL, + mediaUrl: firstImageUrl, // Backward compatibility + medias: enrichment?.medias || [], description: '', lastSeenDate: new Date(), kingdom: enrichment?.kingdom || '', diff --git a/src/lib/media/MediaSource.ts b/src/lib/media/MediaSource.ts index cfcf8d7f..31c0c3e3 100644 --- a/src/lib/media/MediaSource.ts +++ b/src/lib/media/MediaSource.ts @@ -37,4 +37,15 @@ export abstract class MediaSource { } return new Promise((_) => null); } + + /** + * Returns the credits for a given media item. Used when the media + * are fetched without credits information, + * to enrich them with the necessary details to be displayed in the UI. + * @param {Media} media - The media item. + * @returns {Promise} The media item with credits information. + */ + getCredits(media: Media): Promise { + throw new Error('Not implemented'); + } } diff --git a/src/lib/media/Wikidata.ts b/src/lib/media/Wikidata.ts index 84960ec4..b30c64ff 100644 --- a/src/lib/media/Wikidata.ts +++ b/src/lib/media/Wikidata.ts @@ -43,59 +43,6 @@ function fetchWikidataEntityByProperty( }); } -/** - * Fetch metadata for a Commons file (image or audio) - */ -function fetchCommonsMedia( - fileName: string, - typeMedia: MediaType -): Promise { - const commonsUrl = `https://commons.wikimedia.org/w/api.php?action=query&titles=File:${encodeURIComponent( - fileName - )}&prop=imageinfo&iiprop=url|extmetadata&format=json&origin=*`; - - return fetch(commonsUrl) - .then((res) => res.json()) - .then((commonsData) => { - const pages = commonsData.query.pages; - const page = Object.values(pages)[0] as any; - - if (!page?.imageinfo?.length) { - return []; - } - - const info = page.imageinfo[0]; - const meta = info.extmetadata || {}; - const credit = { - artist: meta.Artist?.value || null, - license: meta.LicenseShortName?.value || null, - creditLine: meta.Credit?.value || null, - licenseUrl: meta.LicenseUrl?.value || null, - }; - - return [ - { - url: - typeMedia === MediaType.image - ? `https://commons.wikimedia.org/w/thumb.php?width=400&f=${fileName}` - : info.url, - source: `${ - credit.artist - ? credit.artist.replace(/<[^>]*>?/gm, '') - : '' - } - ${credit.license}`, - typeMedia, - licenseUrl: credit.licenseUrl, - license: credit.license, - author: credit.artist - ? credit.artist.replace(/<[^>]*>?/gm, '') - : credit.artist, - urlSource: `https://commons.wikimedia.org/wiki/File:${fileName}`, - }, - ] as Media[]; - }); -} - /** * Generic fetcher for Wikidata media (image/audio) */ @@ -122,7 +69,52 @@ function fetchMediaFromWikidata( } const fileName = mediaClaims[0].mainsnak.datavalue.value; - return fetchCommonsMedia(fileName, typeMedia); + + // Inline fetch Commons metadata + const commonsUrl = `https://commons.wikimedia.org/w/api.php?action=query&titles=File:${encodeURIComponent( + fileName + )}&prop=imageinfo&iiprop=url|extmetadata&format=json&origin=*`; + + return fetch(commonsUrl) + .then((res) => res.json()) + .then((commonsData) => { + const pages = commonsData.query.pages; + const page = Object.values(pages)[0] as any; + + if (!page?.imageinfo?.length) { + return []; + } + + const info = page.imageinfo[0]; + const meta = info.extmetadata || {}; + const credit = { + artist: meta.Artist?.value || null, + license: meta.LicenseShortName?.value || null, + creditLine: meta.Credit?.value || null, + licenseUrl: meta.LicenseUrl?.value || null, + }; + + return [ + { + url: + typeMedia === MediaType.image + ? `https://commons.wikimedia.org/w/thumb.php?width=700&f=${fileName}` + : info.url, + source: `${ + credit.artist + ? credit.artist.replace(/<[^>]*>?/gm, '') + : '' + } - ${credit.license}`, + typeMedia, + licenseUrl: credit.licenseUrl, + license: credit.license, + author: credit.artist + ? credit.artist.replace(/<[^>]*>?/gm, '') + : credit.artist, + urlSource: `https://commons.wikimedia.org/wiki/File:${fileName}`, + }, + ] as Media[]; + }); }); } @@ -207,6 +199,66 @@ class WikiDataImageSource extends MediaSource { isCompatible(connector: any): boolean { return [GBIF, TAXREF].includes(connector.referential); } + + /** + * Fetch full credits for a media item that only has basic info. + * Expects the filename to be stored in media.source field. + * @param {Media} media - The media item with basic info (must have source field with filename) + * @returns {Promise} The media item enriched with full credits + */ + async getCredits(media: Media): Promise { + if (!media.source) { + throw new Error('Media source field must contain the filename'); + } + + const fileName = media.source; + const typeMedia = media.typeMedia; + + // Fetch metadata from Commons API + const commonsUrl = `https://commons.wikimedia.org/w/api.php?action=query&titles=File:${encodeURIComponent( + fileName + )}&prop=imageinfo&iiprop=url|extmetadata&format=json&origin=*`; + + try { + const res = await fetch(commonsUrl); + const commonsData = await res.json(); + const pages = commonsData.query.pages; + const page = Object.values(pages)[0] as any; + + if (!page?.imageinfo?.length) { + return media; // Return original if no info found + } + + const info = page.imageinfo[0]; + const meta = info.extmetadata || {}; + const credit = { + artist: meta.Artist?.value || null, + license: meta.LicenseShortName?.value || null, + creditLine: meta.Credit?.value || null, + licenseUrl: meta.LicenseUrl?.value || null, + }; + + return { + url: + typeMedia === MediaType.image + ? `https://commons.wikimedia.org/w/thumb.php?width=700&f=${fileName}` + : info.url, + source: `${ + credit.artist ? credit.artist.replace(/<[^>]*>?/gm, '') : '' + } - ${credit.license}`, + typeMedia, + licenseUrl: credit.licenseUrl, + license: credit.license, + author: credit.artist + ? credit.artist.replace(/<[^>]*>?/gm, '') + : credit.artist, + urlSource: `https://commons.wikimedia.org/wiki/File:${fileName}`, + } as Media; + } catch (error) { + console.error('Failed to fetch Commons media credits:', error); + return media; // Return original on error + } + } } export { WikiDataImageSource }; diff --git a/src/lib/models.ts b/src/lib/models.ts index 0c17be94..ab9b7e55 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -3,7 +3,8 @@ export interface Taxon { acceptedScientificName: string; vernacularName?: string; nbObservations?: number; - mediaUrl?: string; + mediaUrl?: string; // Deprecated: use medias instead + medias?: Media[]; taxonRank?: string; description?: string; taxonSheetUrl?: string; From 94b363cde088b0dffc9fecd2b3778641131f51e5 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Tue, 26 May 2026 21:09:53 +0200 Subject: [PATCH 09/10] fix status icon use --- .../core/taxonList/TaxonThumbnail.vue | 2 +- src/components/core/taxonList/TaxonView.vue | 19 +++++++++++++------ src/lib/connectors/connector.ts | 2 +- src/lib/connectors/gbiffacet.ts | 5 +---- src/lib/connectors/geonature.ts | 7 +++---- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/components/core/taxonList/TaxonThumbnail.vue b/src/components/core/taxonList/TaxonThumbnail.vue index 7ea069af..6b6eec4c 100644 --- a/src/components/core/taxonList/TaxonThumbnail.vue +++ b/src/components/core/taxonList/TaxonThumbnail.vue @@ -47,7 +47,7 @@
{ - status.value = { - status: status_, - color: connector.getStatusColor(status_), - }; - }); + connector + .fetchTaxonStatus(taxon.taxonId) + .then((status_) => { + if (status_) { + status.value = { + status: status_, + color: connector.getStatusColor(status_), + }; + } + }) + .catch((err) => { + console.warn('Failed to fetch taxon status:', err); + }); } fetchTaxonImage(); diff --git a/src/lib/connectors/connector.ts b/src/lib/connectors/connector.ts index b43e2c6b..b46b34ea 100644 --- a/src/lib/connectors/connector.ts +++ b/src/lib/connectors/connector.ts @@ -254,7 +254,7 @@ export class Connector { * @returns {Promise} A promise that resolves to the conservation status of the taxon. */ fetchTaxonStatus(taxonId: string | number): Promise { - return null; + return Promise.resolve(null); } /** diff --git a/src/lib/connectors/gbiffacet.ts b/src/lib/connectors/gbiffacet.ts index 657cf275..076a9b0a 100644 --- a/src/lib/connectors/gbiffacet.ts +++ b/src/lib/connectors/gbiffacet.ts @@ -384,10 +384,7 @@ export class GbifFacetConnector extends Connector { fetchTaxonStatus(idTaxon: string) { return fetchJson<{ category: string; code: string }>( `${this.GBIF_ENDPOINT}/species/${idTaxon}/iucnRedListCategory` - ).then((json) => ({ - iucnRedListCategory: json.category, - code: json.code, - })); + ).then((json) => json.code); } searchTaxon(searchString: string = '', params: OccurrenceParams = {}) { diff --git a/src/lib/connectors/geonature.ts b/src/lib/connectors/geonature.ts index 024ca01b..1db82d26 100644 --- a/src/lib/connectors/geonature.ts +++ b/src/lib/connectors/geonature.ts @@ -138,10 +138,9 @@ export class GeoNatureConnector extends Connector { } fetchTaxonStatus(idTaxon: string): Promise { - const url = `https://taxref.mnhn.fr/api/taxa/${idTaxon}/status/columns`; - return fetch(url) - .then((response) => response.json()) - .then((json) => json._embedded?.status); + // GeoNature uses Taxref status system, not IUCN Red List + // Return null to indicate IUCN status is not available + return Promise.resolve(null); } getTaxonDetailPage(taxonId: string): string { From b485682e54c2db19fb44d18fd9fcad6bbd9d17cc Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Tue, 26 May 2026 22:19:33 +0200 Subject: [PATCH 10/10] feat: add progress tracking since gbif facet data loading can be pretty long --- src/assets/languageAssets/cs.js | 6 + src/assets/languageAssets/de.js | 6 + src/assets/languageAssets/en.js | 6 + src/assets/languageAssets/es.js | 6 + src/assets/languageAssets/fr.js | 6 + src/assets/languageAssets/it.js | 6 + src/components/commons/ProgressBar.vue | 120 ++++++++++++++++++ src/components/core/taxonList/TaxonList.vue | 4 + .../core/taxonList/TaxonListMessages.vue | 31 ++++- .../core/taxonList/taxonListManager.ts | 25 ++++ src/lib/connectors/connector.ts | 17 +++ src/lib/connectors/gbiffacet.ts | 14 ++ 12 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 src/components/commons/ProgressBar.vue diff --git a/src/assets/languageAssets/cs.js b/src/assets/languageAssets/cs.js index 6640263e..0cd568d5 100644 --- a/src/assets/languageAssets/cs.js +++ b/src/assets/languageAssets/cs.js @@ -88,6 +88,12 @@ Výsledky můžete také **sdílet 📤** a spolupracovat s ostatními uživatel apiEndpoint: 'API endpoint GBIF', howToContribute: "Pokud chcete přispět k otevřeným datům pozorování biodiverzity agregovaným v GBIF, můžete využít kolaborativní platformy jako iNaturalist, Pl{'@'}ntNet nebo Observation.org.", + progress: { + fetching: 'Načítání pozorování...', + processing: 'Zpracování dat...', + enriching: 'Získávání informací o taxonech...', + finalizing: 'Dokončování...', + }, }, geonature: { api_endpoint: 'API endpoint GeoNature', diff --git a/src/assets/languageAssets/de.js b/src/assets/languageAssets/de.js index 844d2dae..9fc562a7 100644 --- a/src/assets/languageAssets/de.js +++ b/src/assets/languageAssets/de.js @@ -88,6 +88,12 @@ Sie können Ihre Ergebnisse außerdem **teilen 📤**, um mit anderen Nutzern zu apiEndpoint: 'API-Adresse von GBIF', howToContribute: "Wenn Sie zu den offenen Biodiversitätsbeobachtungsdaten beitragen möchten, die im GBIF aggregiert sind, können Sie kollaborative Plattformen wie iNaturalist, Pl{'@'}ntNet oder Observation.org nutzen.", + progress: { + fetching: 'Beobachtungen abrufen...', + processing: 'Daten verarbeiten...', + enriching: 'Taxon-Informationen abrufen...', + finalizing: 'Abschließen...', + }, }, geonature: { api_endpoint: 'API-Adresse von GeoNature', diff --git a/src/assets/languageAssets/en.js b/src/assets/languageAssets/en.js index 0004fd6a..119ef3c7 100644 --- a/src/assets/languageAssets/en.js +++ b/src/assets/languageAssets/en.js @@ -89,6 +89,12 @@ You can also **share your results 📤** to collaborate with other users. apiEndpoint: 'API endpoint of the GBIF', howToContribute: "If you would like to contribute to the open biodiversity observation data aggregated in GBIF, you can use collaborative platforms such as iNaturalist, Pl{'@'}ntNet or Observation.org.", + progress: { + fetching: 'Fetching observations...', + processing: 'Processing data...', + enriching: 'Retrieving latest taxon information...', + finalizing: 'Finalizing...', + }, }, geonature: { api_endpoint: 'GeoNature API endpoint', diff --git a/src/assets/languageAssets/es.js b/src/assets/languageAssets/es.js index 5feaa357..f9266aa1 100644 --- a/src/assets/languageAssets/es.js +++ b/src/assets/languageAssets/es.js @@ -83,6 +83,12 @@ También puedes **compartir tus resultados 📤** para colaborar con otros usuar apiEndpoint: 'Punto final de la API de GBIF', howToContribute: "Si desea contribuir a los datos abiertos de observaciones de biodiversidad agregados en GBIF, puede utilizar plataformas colaborativas como iNaturalist, Pl{'@'}ntNet o Observation.org.", + progress: { + fetching: 'Recuperando observaciones...', + processing: 'Procesando datos...', + enriching: 'Recuperando información de taxones...', + finalizing: 'Finalizando...', + }, }, geonature: { api_endpoint: 'Dirección de la API de GeoNature', diff --git a/src/assets/languageAssets/fr.js b/src/assets/languageAssets/fr.js index 35693ec4..8eaca8d4 100644 --- a/src/assets/languageAssets/fr.js +++ b/src/assets/languageAssets/fr.js @@ -88,6 +88,12 @@ Vous pouvez également **partager vos résultats 📤** pour collaborer avec d'a apiEndpoint: "Adresse de l'API du GBIF", howToContribute: "Si vous souhaitez contribuer aux données ouvertes d'observations de biodiversité agrégées dans le GBIF, vous pouvez utiliser des plateformes collaboratives comme iNaturalist, Pl{'@'}ntNet ou Observation.org.", + progress: { + fetching: 'Récupération des observations...', + processing: 'Traitement des données...', + enriching: 'Récupération des dernières infos sur les taxons...', + finalizing: 'Finalisation...', + }, }, geonature: { api_endpoint: "Adresse de l'API de GeoNature", diff --git a/src/assets/languageAssets/it.js b/src/assets/languageAssets/it.js index 4973aa7d..3dac0a69 100644 --- a/src/assets/languageAssets/it.js +++ b/src/assets/languageAssets/it.js @@ -89,6 +89,12 @@ Puoi anche **condividere i tuoi risultati 📤** per collaborare con altri utent apiEndpoint: 'Indirizzo API del GBIF', howToContribute: "Se desideri contribuire ai dati aperti sulle osservazioni della biodiversità aggregati nel GBIF, puoi utilizzare piattaforme collaborative come iNaturalist, Pl@ntNet o Observation.org.", + progress: { + fetching: 'Recupero delle osservazioni...', + processing: 'Elaborazione dei dati...', + enriching: 'Recupero delle informazioni sui taxon...', + finalizing: 'Finalizzazione...', + }, }, geonature: { api_endpoint: 'Indirizzo API di GeoNature', diff --git a/src/components/commons/ProgressBar.vue b/src/components/commons/ProgressBar.vue new file mode 100644 index 00000000..0b425356 --- /dev/null +++ b/src/components/commons/ProgressBar.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/src/components/core/taxonList/TaxonList.vue b/src/components/core/taxonList/TaxonList.vue index d7db541f..5ace17a9 100644 --- a/src/components/core/taxonList/TaxonList.vue +++ b/src/components/core/taxonList/TaxonList.vue @@ -81,6 +81,8 @@ pageIndex, loadingObservations, loadingError, + loadingProgress, + loadingMessage, } = taxonManager; const speciesList = computed(() => searchResult.value.taxons); @@ -168,6 +170,8 @@ diff --git a/src/components/core/taxonList/TaxonListMessages.vue b/src/components/core/taxonList/TaxonListMessages.vue index d6ca8d6a..d07130ed 100644 --- a/src/components/core/taxonList/TaxonListMessages.vue +++ b/src/components/core/taxonList/TaxonListMessages.vue @@ -1,6 +1,7 @@