feat: implement external environmental data display components
- Add WeatherWidget for current weather conditions - Add TideDisplay for tide times and heights - Add WaterQualityIndicator for EPA water quality data - Add BeachQualityDisplay for beach ratings and certifications - Add BiodiversityInfo for marine biodiversity information - Add EnvironmentalDataPanel composite component - Create externalDataService for API integration - Add types for external environmental data - Create demo page at /demo/environmental-data - Add comprehensive README documentation Fulfills requirements 11.1, 11.2, 11.3, 11.4, 11.6, 11.7 Completes task 19 from implementation plan
This commit is contained in:
131
src/components/external/BeachQualityDisplay.tsx
vendored
Normal file
131
src/components/external/BeachQualityDisplay.tsx
vendored
Normal file
@@ -0,0 +1,131 @@
|
||||
// Beach quality rating display component
|
||||
import { useEffect, useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import type { BeachQualityData } from '@/types';
|
||||
import { externalDataService } from '@/services/externalDataService';
|
||||
|
||||
interface BeachQualityDisplayProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export function BeachQualityDisplay({ latitude, longitude }: BeachQualityDisplayProps) {
|
||||
const [beachQuality, setBeachQuality] = useState<BeachQualityData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const fetchBeachQuality = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await externalDataService.getBeachQuality(latitude, longitude);
|
||||
if (mounted) {
|
||||
setBeachQuality(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
setError('Unable to load beach quality data');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchBeachQuality();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [latitude, longitude]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm text-gray-600">Loading beach quality...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !beachQuality) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<p className="text-sm text-gray-500">Beach quality data unavailable</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ratingConfig = {
|
||||
'blue-flag': { color: 'text-blue-600', icon: '🏴', label: 'Blue Flag Award' },
|
||||
'green-coast': { color: 'text-green-600', icon: '🌿', label: 'Green Coast Award' },
|
||||
'excellent': { color: 'text-green-600', icon: '⭐', label: 'Excellent' },
|
||||
'good': { color: 'text-blue-600', icon: '✓', label: 'Good' },
|
||||
'adequate': { color: 'text-yellow-600', icon: '○', label: 'Adequate' },
|
||||
'poor': { color: 'text-red-600', icon: '✗', label: 'Poor' },
|
||||
'unknown': { color: 'text-gray-500', icon: '?', label: 'Unknown' },
|
||||
};
|
||||
|
||||
const config = ratingConfig[beachQuality.rating];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center">
|
||||
<span className="mr-2">🏖️</span>
|
||||
Beach Quality
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-3xl">{config.icon}</span>
|
||||
<div>
|
||||
<div className={`font-semibold text-lg ${config.color}`}>
|
||||
{config.label}
|
||||
</div>
|
||||
{beachQuality.lastInspection && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Last inspection: {format(new Date(beachQuality.lastInspection), 'MMM d, yyyy')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{beachQuality.awards && beachQuality.awards.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">Awards & Certifications</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{beachQuality.awards.map((award, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full"
|
||||
>
|
||||
{award}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{beachQuality.facilities && beachQuality.facilities.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">Facilities</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
{beachQuality.facilities.map((facility, index) => (
|
||||
<div key={index} className="flex items-center space-x-1">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span className="text-gray-700">{facility}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
src/components/external/BiodiversityInfo.tsx
vendored
Normal file
125
src/components/external/BiodiversityInfo.tsx
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
// Marine biodiversity information component
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { BiodiversityData } from '@/types';
|
||||
import { externalDataService } from '@/services/externalDataService';
|
||||
|
||||
interface BiodiversityInfoProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export function BiodiversityInfo({ latitude, longitude }: BiodiversityInfoProps) {
|
||||
const [biodiversity, setBiodiversity] = useState<BiodiversityData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const fetchBiodiversity = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await externalDataService.getBiodiversity(latitude, longitude);
|
||||
if (mounted) {
|
||||
setBiodiversity(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
setError('Unable to load biodiversity data');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchBiodiversity();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [latitude, longitude]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm text-gray-600">Loading biodiversity data...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !biodiversity) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<p className="text-sm text-gray-500">Biodiversity data unavailable</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't render if there's no meaningful data
|
||||
if (!biodiversity.species?.length && !biodiversity.protectedArea && !biodiversity.habitatType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center">
|
||||
<span className="mr-2">🐚</span>
|
||||
Marine Biodiversity
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{biodiversity.protectedArea && (
|
||||
<div className="p-3 bg-green-50 rounded-lg border border-green-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-green-600 text-xl">🛡️</span>
|
||||
<span className="text-sm font-medium text-green-800">Protected Area</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{biodiversity.habitatType && (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700 mb-1">Habitat Type</div>
|
||||
<div className="text-sm text-gray-600">{biodiversity.habitatType}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{biodiversity.conservationStatus && (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700 mb-1">Conservation Status</div>
|
||||
<div className="text-sm text-gray-600">{biodiversity.conservationStatus}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{biodiversity.species && biodiversity.species.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">
|
||||
Notable Species ({biodiversity.species.length})
|
||||
</div>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{biodiversity.species.map((species, index) => (
|
||||
<div key={index} className="p-2 bg-gray-50 rounded text-sm">
|
||||
<div className="font-medium text-gray-800">{species.name}</div>
|
||||
{species.scientificName && (
|
||||
<div className="text-xs text-gray-500 italic">{species.scientificName}</div>
|
||||
)}
|
||||
{species.status && (
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
Status: {species.status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/components/external/EnvironmentalDataPanel.tsx
vendored
Normal file
35
src/components/external/EnvironmentalDataPanel.tsx
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
// Composite panel displaying all external environmental data
|
||||
import { WeatherWidget } from './WeatherWidget';
|
||||
import { TideDisplay } from './TideDisplay';
|
||||
import { WaterQualityIndicator } from './WaterQualityIndicator';
|
||||
import { BeachQualityDisplay } from './BeachQualityDisplay';
|
||||
import { BiodiversityInfo } from './BiodiversityInfo';
|
||||
|
||||
interface EnvironmentalDataPanelProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EnvironmentalDataPanel({
|
||||
latitude,
|
||||
longitude,
|
||||
className = '',
|
||||
}: EnvironmentalDataPanelProps) {
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-2">Environmental Context</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Real-time environmental data for this location
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<WeatherWidget latitude={latitude} longitude={longitude} />
|
||||
<TideDisplay latitude={latitude} longitude={longitude} />
|
||||
<WaterQualityIndicator latitude={latitude} longitude={longitude} />
|
||||
<BeachQualityDisplay latitude={latitude} longitude={longitude} />
|
||||
<BiodiversityInfo latitude={latitude} longitude={longitude} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
src/components/external/README.md
vendored
Normal file
159
src/components/external/README.md
vendored
Normal file
@@ -0,0 +1,159 @@
|
||||
# External Environmental Data Components
|
||||
|
||||
This directory contains components for displaying external environmental data from various public APIs and data sources.
|
||||
|
||||
## Components
|
||||
|
||||
### WeatherWidget
|
||||
Displays current weather conditions for a location including:
|
||||
- Temperature and "feels like" temperature
|
||||
- Humidity percentage
|
||||
- Wind speed and direction
|
||||
- Weather description
|
||||
|
||||
**Props:**
|
||||
- `latitude: number` - Location latitude
|
||||
- `longitude: number` - Location longitude
|
||||
|
||||
**API Endpoint:** `GET /api/external/weather/:lat/:lng`
|
||||
|
||||
### TideDisplay
|
||||
Shows tide information for coastal locations including:
|
||||
- Next tide time and type (high/low)
|
||||
- Today's tide schedule with times and heights
|
||||
- Tide heights in meters
|
||||
|
||||
**Props:**
|
||||
- `latitude: number` - Location latitude
|
||||
- `longitude: number` - Location longitude
|
||||
|
||||
**API Endpoint:** `GET /api/external/tides/:lat/:lng`
|
||||
|
||||
### WaterQualityIndicator
|
||||
Displays water quality status from EPA data:
|
||||
- Overall quality rating (excellent, good, sufficient, poor)
|
||||
- Water quality parameters (bacteria, pH, temperature)
|
||||
- Last update date
|
||||
- Data source information
|
||||
|
||||
**Props:**
|
||||
- `latitude: number` - Location latitude
|
||||
- `longitude: number` - Location longitude
|
||||
|
||||
**API Endpoint:** `GET /api/external/water-quality/:lat/:lng`
|
||||
|
||||
### BeachQualityDisplay
|
||||
Shows beach quality ratings and certifications:
|
||||
- Quality rating (Blue Flag, Green Coast, etc.)
|
||||
- Awards and certifications
|
||||
- Available facilities
|
||||
- Last inspection date
|
||||
|
||||
**Props:**
|
||||
- `latitude: number` - Location latitude
|
||||
- `longitude: number` - Location longitude
|
||||
|
||||
**API Endpoint:** `GET /api/external/beach-quality/:lat/:lng`
|
||||
|
||||
### BiodiversityInfo
|
||||
Displays marine biodiversity information:
|
||||
- Protected area status
|
||||
- Habitat type
|
||||
- Conservation status
|
||||
- Notable species list with scientific names
|
||||
|
||||
**Props:**
|
||||
- `latitude: number` - Location latitude
|
||||
- `longitude: number` - Location longitude
|
||||
|
||||
**API Endpoint:** `GET /api/external/biodiversity/:lat/:lng`
|
||||
|
||||
**Note:** This component only renders if meaningful data is available.
|
||||
|
||||
### EnvironmentalDataPanel
|
||||
Composite component that displays all external environmental data widgets together.
|
||||
|
||||
**Props:**
|
||||
- `latitude: number` - Location latitude
|
||||
- `longitude: number` - Location longitude
|
||||
- `className?: string` - Optional CSS classes
|
||||
|
||||
## Usage
|
||||
|
||||
### Individual Components
|
||||
|
||||
```tsx
|
||||
import { WeatherWidget, TideDisplay } from '@/components/external';
|
||||
|
||||
function MyComponent() {
|
||||
return (
|
||||
<div>
|
||||
<WeatherWidget latitude={53.3498} longitude={-6.2603} />
|
||||
<TideDisplay latitude={53.3498} longitude={-6.2603} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Composite Panel
|
||||
|
||||
```tsx
|
||||
import { EnvironmentalDataPanel } from '@/components/external';
|
||||
|
||||
function ReportDetails({ report }) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Report Details</h2>
|
||||
{/* ... report info ... */}
|
||||
|
||||
<EnvironmentalDataPanel
|
||||
latitude={report.latitude}
|
||||
longitude={report.longitude}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
All components handle loading and error states gracefully:
|
||||
- **Loading state:** Shows a spinner with loading message
|
||||
- **Error state:** Displays "data unavailable" message
|
||||
- **No data:** Components hide themselves or show appropriate message
|
||||
|
||||
Components use `try-catch` blocks and never throw errors to parent components.
|
||||
|
||||
## Data Caching
|
||||
|
||||
External API responses are cached on the backend for up to 6 hours to:
|
||||
- Minimize API calls to external services
|
||||
- Improve performance
|
||||
- Reduce costs
|
||||
- Ensure data availability
|
||||
|
||||
## Backend Requirements
|
||||
|
||||
These components require the following backend API endpoints to be implemented:
|
||||
|
||||
1. `GET /api/external/weather/:lat/:lng` - Weather data from OpenWeatherMap
|
||||
2. `GET /api/external/tides/:lat/:lng` - Tide data from WorldTides or NOAA
|
||||
3. `GET /api/external/water-quality/:lat/:lng` - EPA water quality data
|
||||
4. `GET /api/external/beach-quality/:lat/:lng` - Beach quality ratings
|
||||
5. `GET /api/external/biodiversity/:lat/:lng` - Marine biodiversity information
|
||||
|
||||
See the design document for detailed API specifications and data formats.
|
||||
|
||||
## Demo
|
||||
|
||||
Visit `/demo/environmental-data` to see all components in action with sample locations.
|
||||
|
||||
## Requirements Fulfilled
|
||||
|
||||
These components fulfill the following requirements:
|
||||
- **11.1** - Weather data integration
|
||||
- **11.2** - Tide information display
|
||||
- **11.3** - Water quality data from EPA
|
||||
- **11.4** - Beach quality ratings
|
||||
- **11.6** - Beach quality from EU databases
|
||||
- **11.7** - Marine biodiversity information
|
||||
107
src/components/external/TideDisplay.tsx
vendored
Normal file
107
src/components/external/TideDisplay.tsx
vendored
Normal file
@@ -0,0 +1,107 @@
|
||||
// Tide times display component
|
||||
import { useEffect, useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import type { TideData } from '@/types';
|
||||
import { externalDataService } from '@/services/externalDataService';
|
||||
|
||||
interface TideDisplayProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export function TideDisplay({ latitude, longitude }: TideDisplayProps) {
|
||||
const [tideData, setTideData] = useState<TideData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const fetchTides = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await externalDataService.getTides(latitude, longitude);
|
||||
if (mounted) {
|
||||
setTideData(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
setError('Unable to load tide data');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchTides();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [latitude, longitude]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm text-gray-600">Loading tide data...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !tideData) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<p className="text-sm text-gray-500">Tide data unavailable</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center">
|
||||
<span className="mr-2">🌊</span>
|
||||
Tide Times
|
||||
</h3>
|
||||
|
||||
{tideData.nextTide && (
|
||||
<div className="mb-4 p-3 bg-blue-50 rounded-lg">
|
||||
<div className="text-sm text-gray-600 mb-1">Next Tide</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold capitalize">{tideData.nextTide.type} Tide</span>
|
||||
<span className="text-sm">{format(new Date(tideData.nextTide.time), 'HH:mm')}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
Height: {tideData.nextTide.height.toFixed(2)}m
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">Today's Tides</div>
|
||||
{tideData.tides.slice(0, 4).map((tide, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between py-2 border-b border-gray-100 last:border-0"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-lg">{tide.type === 'high' ? '⬆️' : '⬇️'}</span>
|
||||
<div>
|
||||
<div className="font-medium capitalize">{tide.type} Tide</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{format(new Date(tide.time), 'HH:mm')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">{tide.height.toFixed(2)}m</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/components/external/WaterQualityIndicator.tsx
vendored
Normal file
131
src/components/external/WaterQualityIndicator.tsx
vendored
Normal file
@@ -0,0 +1,131 @@
|
||||
// Water quality indicator component
|
||||
import { useEffect, useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import type { WaterQualityData } from '@/types';
|
||||
import { externalDataService } from '@/services/externalDataService';
|
||||
|
||||
interface WaterQualityIndicatorProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export function WaterQualityIndicator({ latitude, longitude }: WaterQualityIndicatorProps) {
|
||||
const [waterQuality, setWaterQuality] = useState<WaterQualityData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const fetchWaterQuality = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await externalDataService.getWaterQuality(latitude, longitude);
|
||||
if (mounted) {
|
||||
setWaterQuality(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
setError('Unable to load water quality data');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchWaterQuality();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [latitude, longitude]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm text-gray-600">Loading water quality...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !waterQuality) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<p className="text-sm text-gray-500">Water quality data unavailable</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
excellent: { color: 'bg-green-500', text: 'Excellent', icon: '✓' },
|
||||
good: { color: 'bg-blue-500', text: 'Good', icon: '✓' },
|
||||
sufficient: { color: 'bg-yellow-500', text: 'Sufficient', icon: '!' },
|
||||
poor: { color: 'bg-red-500', text: 'Poor', icon: '✗' },
|
||||
unknown: { color: 'bg-gray-400', text: 'Unknown', icon: '?' },
|
||||
};
|
||||
|
||||
const config = statusConfig[waterQuality.status];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center">
|
||||
<span className="mr-2">💧</span>
|
||||
Water Quality
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-12 h-12 ${config.color} rounded-full flex items-center justify-center text-white text-xl font-bold`}>
|
||||
{config.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-lg">{config.text}</div>
|
||||
{waterQuality.lastUpdated && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Updated: {format(new Date(waterQuality.lastUpdated), 'MMM d, yyyy')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{waterQuality.parameters && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">Parameters</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
{waterQuality.parameters.bacteria !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-600">Bacteria:</span>
|
||||
<span className="ml-2 font-medium">{waterQuality.parameters.bacteria} CFU/100ml</span>
|
||||
</div>
|
||||
)}
|
||||
{waterQuality.parameters.ph !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-600">pH:</span>
|
||||
<span className="ml-2 font-medium">{waterQuality.parameters.ph.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
{waterQuality.parameters.temperature !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-600">Temp:</span>
|
||||
<span className="ml-2 font-medium">{waterQuality.parameters.temperature}°C</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{waterQuality.source && (
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
Source: {waterQuality.source}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
src/components/external/WeatherWidget.tsx
vendored
Normal file
104
src/components/external/WeatherWidget.tsx
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
// Weather information widget component
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { WeatherData } from '@/types';
|
||||
import { externalDataService } from '@/services/externalDataService';
|
||||
|
||||
interface WeatherWidgetProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export function WeatherWidget({ latitude, longitude }: WeatherWidgetProps) {
|
||||
const [weather, setWeather] = useState<WeatherData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const fetchWeather = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await externalDataService.getWeather(latitude, longitude);
|
||||
if (mounted) {
|
||||
setWeather(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
setError('Unable to load weather data');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchWeather();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [latitude, longitude]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm text-gray-600">Loading weather...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !weather) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<p className="text-sm text-gray-500">Weather data unavailable</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center">
|
||||
<span className="mr-2">🌤️</span>
|
||||
Weather Conditions
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xl font-bold">{Math.round(weather.temperature)}°C</span>
|
||||
<span className="text-gray-600 capitalize">{weather.description}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-600">Feels like:</span>
|
||||
<span className="ml-2 font-medium">{Math.round(weather.feelsLike)}°C</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Humidity:</span>
|
||||
<span className="ml-2 font-medium">{weather.humidity}%</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Wind:</span>
|
||||
<span className="ml-2 font-medium">{Math.round(weather.windSpeed * 3.6)} km/h</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Direction:</span>
|
||||
<span className="ml-2 font-medium">{getWindDirection(weather.windDirection)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getWindDirection(degrees: number): string {
|
||||
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||
const index = Math.round(degrees / 45) % 8;
|
||||
return directions[index] || 'N';
|
||||
}
|
||||
7
src/components/external/index.ts
vendored
Normal file
7
src/components/external/index.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// External environmental data components
|
||||
export { WeatherWidget } from './WeatherWidget';
|
||||
export { TideDisplay } from './TideDisplay';
|
||||
export { WaterQualityIndicator } from './WaterQualityIndicator';
|
||||
export { BeachQualityDisplay } from './BeachQualityDisplay';
|
||||
export { BiodiversityInfo } from './BiodiversityInfo';
|
||||
export { EnvironmentalDataPanel } from './EnvironmentalDataPanel';
|
||||
@@ -12,3 +12,6 @@ export * from './report';
|
||||
|
||||
// Event components
|
||||
export * from './event';
|
||||
|
||||
// External environmental data components
|
||||
export * from './external';
|
||||
|
||||
Reference in New Issue
Block a user