From b0eafebfc409d8c180cd4c538090c1013ee4ca94 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Sun, 16 Nov 2025 23:50:56 +0000 Subject: [PATCH] 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 --- .kiro/specs/beach-litter-mapping/tasks.md | 2 +- server/src/services/externalDataService.ts | 193 ++++++++++++++++-- .../external/EnvironmentalDataPanel.tsx | 4 +- 3 files changed, 178 insertions(+), 21 deletions(-) diff --git a/.kiro/specs/beach-litter-mapping/tasks.md b/.kiro/specs/beach-litter-mapping/tasks.md index f7149e1..495ddd1 100644 --- a/.kiro/specs/beach-litter-mapping/tasks.md +++ b/.kiro/specs/beach-litter-mapping/tasks.md @@ -268,7 +268,7 @@ - Add notification preferences endpoint - _Requirements: 6.3, 6.4, 6.5, 10.4_ -- [x] 32. Integrate external APIs with caching +- [ ] 32. Integrate external APIs with caching - Set up Redis for caching external API responses - Implement weather API integration (OpenWeatherMap) - Build tide API integration (WorldTides or NOAA) diff --git a/server/src/services/externalDataService.ts b/server/src/services/externalDataService.ts index f36548f..7c6dcd6 100644 --- a/server/src/services/externalDataService.ts +++ b/server/src/services/externalDataService.ts @@ -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(); + + // 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(cacheKey); + const cached = cache.get(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(); + + 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(), + }; } }; diff --git a/src/components/external/EnvironmentalDataPanel.tsx b/src/components/external/EnvironmentalDataPanel.tsx index 939f9ae..33934bf 100644 --- a/src/components/external/EnvironmentalDataPanel.tsx +++ b/src/components/external/EnvironmentalDataPanel.tsx @@ -2,6 +2,7 @@ import { WeatherWidget } from './WeatherWidget'; import { TideDisplay } from './TideDisplay'; import { WaterQualityIndicator } from './WaterQualityIndicator'; +import { BiodiversityInfo } from './BiodiversityInfo'; interface EnvironmentalDataPanelProps { latitude: number; @@ -19,13 +20,14 @@ export function EnvironmentalDataPanel({

Environmental Context

- Weather, tide, and water quality data for this location + Weather, tide, water quality, and biodiversity data for this location

+ ); }