feat: implement GBIF biodiversity API integration (partial)

- Add GBIF API integration for marine species observations
- Implement species categorization (birds, fish, mammals, algae, etc.)
- Add Marine Protected Area detection for 7 Irish MPAs
- Determine habitat types from observations
- Add BiodiversityInfo component back to EnvironmentalDataPanel
- Free API, no key required

Note: GBIF API parameters need adjustment - currently returning 400 error
This commit is contained in:
2025-11-16 23:50:56 +00:00
parent 5812906758
commit b0eafebfc4
3 changed files with 178 additions and 21 deletions

View File

@@ -414,6 +414,105 @@ function toRadians(degrees: number): number {
return degrees * (Math.PI / 180);
}
/**
* Determine species category from GBIF record
*/
function determineCategory(record: any): string {
const kingdom = record.kingdom?.toLowerCase() || '';
const phylum = record.phylum?.toLowerCase() || '';
const className = record.class?.toLowerCase() || '';
if (kingdom === 'animalia') {
if (className.includes('aves') || className.includes('bird')) return 'Bird';
if (className.includes('mammalia') || className.includes('mammal')) return 'Marine Mammal';
if (className.includes('actinopterygii') || phylum.includes('chordata')) return 'Fish';
if (phylum.includes('mollusca')) return 'Mollusk';
if (phylum.includes('arthropoda')) return 'Crustacean';
if (phylum.includes('echinodermata')) return 'Echinoderm';
if (phylum.includes('cnidaria')) return 'Cnidarian';
return 'Marine Animal';
}
if (kingdom === 'plantae') {
if (phylum.includes('rhodophyta')) return 'Red Algae';
if (phylum.includes('phaeophyta') || phylum.includes('ochrophyta')) return 'Brown Algae';
if (phylum.includes('chlorophyta')) return 'Green Algae';
return 'Marine Plant';
}
if (kingdom === 'chromista') return 'Algae';
return 'Marine Organism';
}
/**
* Check if location is in a Marine Protected Area
* Simplified check based on known Irish MPAs
*/
function checkMarineProtectedArea(latitude: number, longitude: number): boolean {
// Known Irish Marine Protected Areas (simplified list)
const irishMPAs = [
{ name: 'Dublin Bay', lat: 53.35, lng: -6.15, radius: 10 },
{ name: 'Galway Bay', lat: 53.25, lng: -9.05, radius: 15 },
{ name: 'Bantry Bay', lat: 51.68, lng: -9.45, radius: 12 },
{ name: 'Lough Hyne', lat: 51.50, lng: -9.30, radius: 5 },
{ name: 'Roaringwater Bay', lat: 51.52, lng: -9.55, radius: 10 },
{ name: 'Clew Bay', lat: 53.85, lng: -9.70, radius: 15 },
{ name: 'Donegal Bay', lat: 54.60, lng: -8.45, radius: 20 },
];
for (const mpa of irishMPAs) {
const distance = calculateDistance(latitude, longitude, mpa.lat, mpa.lng);
if (distance < mpa.radius) {
return true;
}
}
return false;
}
/**
* Determine habitat types based on location and observations
*/
function determineHabitatTypes(latitude: number, longitude: number, observations: any[]): string[] {
const habitats = new Set<string>();
// Always add coastal for Irish locations
habitats.add('Coastal');
// Check for specific habitat indicators in observations
observations.forEach((obs: any) => {
const habitat = obs.habitat?.toLowerCase() || '';
const locality = obs.locality?.toLowerCase() || '';
if (habitat.includes('rocky') || locality.includes('rocky')) {
habitats.add('Rocky Shore');
}
if (habitat.includes('sand') || locality.includes('sand') || locality.includes('beach')) {
habitats.add('Sandy Beach');
}
if (habitat.includes('estuar') || locality.includes('estuar')) {
habitats.add('Estuarine');
}
if (habitat.includes('reef') || locality.includes('reef')) {
habitats.add('Reef');
}
if (habitat.includes('kelp') || locality.includes('kelp')) {
habitats.add('Kelp Forest');
}
if (habitat.includes('mudflat') || locality.includes('mudflat')) {
habitats.add('Mudflat');
}
});
// If no specific habitats found, add generic ones
if (habitats.size === 1) {
habitats.add('Intertidal Zone');
}
return Array.from(habitats);
}
/**
* Get beach quality rating data
* This would integrate with Blue Flag, Green Coast Award databases
@@ -476,8 +575,8 @@ export interface BiodiversityData {
}
/**
* Get marine biodiversity data for a location
* This would integrate with GBIF, Marine Institute Ireland, or similar databases
* Get marine biodiversity data for a location using GBIF API
* GBIF (Global Biodiversity Information Facility) - Free, no API key required
*/
export const getBiodiversityData = async (
latitude: number,
@@ -487,36 +586,92 @@ export const getBiodiversityData = async (
try {
// Check cache first
const cached = await cache.get<BiodiversityData>(cacheKey);
const cached = cache.get<BiodiversityData>(cacheKey);
if (cached) {
console.log('Biodiversity data retrieved from cache');
return cached;
}
// Note: GBIF or Marine Institute API integration would go here
// For now, return mock data structure
// In production, you would integrate with:
// - GBIF (Global Biodiversity Information Facility)
// - Marine Institute Ireland
// - National Biodiversity Data Centre
console.warn('Biodiversity API not fully implemented - returning placeholder');
// Fetch marine species observations from GBIF within area
// GBIF API: https://www.gbif.org/developer/occurrence
// Use a bounding box instead of radius (more reliable)
const latDelta = 0.05; // ~5km
const lngDelta = 0.05;
const response = await axios.get('https://api.gbif.org/v1/occurrence/search', {
params: {
decimalLatitude: `${latitude - latDelta},${latitude + latDelta}`,
decimalLongitude: `${longitude - lngDelta},${longitude + lngDelta}`,
limit: 50, // Get more results
country: 'IE', // Ireland
hasCoordinate: true,
hasGeospatialIssue: false,
},
timeout: 10000,
});
const results = response.data.results || [];
// Process species data
const speciesMap = new Map<string, any>();
results.forEach((record: any) => {
if (record.species && record.scientificName) {
const key = record.scientificName;
if (!speciesMap.has(key)) {
speciesMap.set(key, {
name: record.vernacularName || record.species,
scientificName: record.scientificName,
category: determineCategory(record),
count: 1,
});
} else {
const existing = speciesMap.get(key);
existing.count += 1;
}
}
});
// Convert to array and sort by count
const species = Array.from(speciesMap.values())
.sort((a, b) => b.count - a.count)
.slice(0, 10) // Top 10 species
.map(({ name, scientificName, category }) => ({
name,
scientificName,
category,
}));
// Check if location is in a Marine Protected Area (simplified check)
const marineProtectedArea = checkMarineProtectedArea(latitude, longitude);
// Determine habitat types based on location
const habitatTypes = determineHabitatTypes(latitude, longitude, results);
const biodiversityData: BiodiversityData = {
species: [],
marineProtectedArea: false,
habitatTypes: [],
source: 'Placeholder',
species,
marineProtectedArea,
habitatTypes,
conservationStatus: species.length > 0 ? 'Active biodiversity recorded' : 'Limited data',
source: 'GBIF (Global Biodiversity Information Facility)',
timestamp: new Date().toISOString(),
};
// Cache the result
await cache.set(cacheKey, biodiversityData, CACHE_EXPIRATION);
cache.set(cacheKey, biodiversityData, CACHE_EXPIRATION);
return biodiversityData;
} catch (error) {
console.error('Error fetching biodiversity data:', error);
return null;
} catch (error: any) {
console.error('Error fetching biodiversity data:', error.message);
// Return minimal data on error
return {
species: [],
marineProtectedArea: false,
habitatTypes: ['Coastal'],
source: 'GBIF (Global Biodiversity Information Facility)',
timestamp: new Date().toISOString(),
};
}
};