Jaithra Polavarapu commited on
Commit ·
889dd1b
0
Parent(s):
CropIntel — HF Space deploy (all-in-one app)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +13 -0
- .env.local.example +10 -0
- .eslintrc.json +3 -0
- .github/workflows/ci.yml +33 -0
- .gitignore +62 -0
- Dockerfile +48 -0
- Dockerfile.ml +19 -0
- GOOGLE_MAPS_SETUP.md +102 -0
- ML_Architecture_Explanation.md +117 -0
- README copy.md +109 -0
- README.md +93 -0
- SECURITY_CHANGES_SUMMARY.md +101 -0
- app/api/health/route.ts +32 -0
- app/api/predict/route.ts +206 -0
- app/globals.css +108 -0
- app/layout.tsx +36 -0
- app/outbreaks/page.tsx +5 -0
- app/page.tsx +554 -0
- combined_training_logs.csv +81 -0
- compare.sh +8 -0
- components/CropSelector.tsx +39 -0
- components/Diagnosis.tsx +322 -0
- components/DiseaseInfo.tsx +121 -0
- components/ExportResults.tsx +113 -0
- components/FarmerRegistration.tsx +280 -0
- components/FarmerVerificationBadge.tsx +33 -0
- components/GoogleMap.tsx +385 -0
- components/HealthComparisonPanel.tsx +226 -0
- components/ImageUpload.tsx +139 -0
- components/LeafletMap.tsx +138 -0
- components/NotificationSystem.tsx +480 -0
- components/OutbreakMap.tsx +297 -0
- components/PredictionHistory.tsx +199 -0
- components/PredictionResults.tsx +168 -0
- components/StateSelector.tsx +38 -0
- components/TipsAndGuidelines.tsx +89 -0
- components/USMap.tsx +138 -0
- components/USOutbreakMap.tsx +418 -0
- components/ui/theme-provider.tsx +11 -0
- components/ui/world-map.tsx +170 -0
- cropintel_model_metrics_overall.csv +5 -0
- cropintel_model_metrics_overall.png +0 -0
- cropintel_model_metrics_per_class.csv +22 -0
- docker-compose.ml.yml +28 -0
- docker-compose.prod.yml +42 -0
- docker-compose.yml +22 -0
- docker/entrypoint.sh +17 -0
- docker/supervisord.conf +29 -0
- docs/CURSOR_AND_COLLABORATORS.md +5 -0
- docs/DEPLOYMENT.md +156 -0
.dockerignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
.next
|
| 3 |
+
.git
|
| 4 |
+
.gitignore
|
| 5 |
+
*.md
|
| 6 |
+
!README.md
|
| 7 |
+
ml/data
|
| 8 |
+
.env
|
| 9 |
+
.env.*
|
| 10 |
+
**/__pycache__
|
| 11 |
+
**/*.pyc
|
| 12 |
+
.venv
|
| 13 |
+
venv
|
.env.local.example
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Google Maps (outbreak map)
|
| 2 |
+
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_key_here
|
| 3 |
+
|
| 4 |
+
# Phone on same Wi‑Fi — use http:// not https:// (example: http://192.168.0.223:3050)
|
| 5 |
+
# Optional: only if you still see a blank page after fixing firewall / same Wi‑Fi:
|
| 6 |
+
# NEXT_DEV_ALLOWED_ORIGINS=192.168.1.42
|
| 7 |
+
# (Run: ipconfig getifaddr en0 — must match exactly; restart `npm run dev` after changing.)
|
| 8 |
+
#
|
| 9 |
+
# If the outbreak map stays blank on the phone, add an HTTP referrer for that URL in
|
| 10 |
+
# Google Cloud Console → APIs & Services → Credentials → your browser key → Website restrictions.
|
.eslintrc.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"extends": "next/core-web-vitals"
|
| 3 |
+
}
|
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
pull_request:
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
web:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
steps:
|
| 12 |
+
- uses: actions/checkout@v4
|
| 13 |
+
- uses: actions/setup-node@v4
|
| 14 |
+
with:
|
| 15 |
+
node-version: 20
|
| 16 |
+
cache: npm
|
| 17 |
+
- run: npm ci
|
| 18 |
+
- run: npm run lint
|
| 19 |
+
- run: npx tsc --noEmit
|
| 20 |
+
- run: npm run build
|
| 21 |
+
|
| 22 |
+
python:
|
| 23 |
+
runs-on: ubuntu-latest
|
| 24 |
+
steps:
|
| 25 |
+
- uses: actions/checkout@v4
|
| 26 |
+
- uses: actions/setup-python@v5
|
| 27 |
+
with:
|
| 28 |
+
python-version: "3.11"
|
| 29 |
+
cache: pip
|
| 30 |
+
cache-dependency-path: ml/requirements-inference.txt
|
| 31 |
+
- run: pip install -r ml/requirements-inference.txt pytest httpx scikit-learn pandas
|
| 32 |
+
# Model weights are not committed; tests marked needs_model auto-skip.
|
| 33 |
+
- run: python -m pytest tests/ -v
|
.gitignore
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies
|
| 2 |
+
node_modules/
|
| 3 |
+
/.pnp
|
| 4 |
+
.pnp.js
|
| 5 |
+
|
| 6 |
+
# Testing
|
| 7 |
+
/coverage
|
| 8 |
+
|
| 9 |
+
# Next.js
|
| 10 |
+
/.next/
|
| 11 |
+
/out/
|
| 12 |
+
|
| 13 |
+
# Production
|
| 14 |
+
/build
|
| 15 |
+
|
| 16 |
+
# Misc
|
| 17 |
+
.DS_Store
|
| 18 |
+
*.pem
|
| 19 |
+
|
| 20 |
+
# Debug
|
| 21 |
+
npm-debug.log*
|
| 22 |
+
yarn-debug.log*
|
| 23 |
+
yarn-error.log*
|
| 24 |
+
|
| 25 |
+
# Local env files
|
| 26 |
+
.env*.local
|
| 27 |
+
.env
|
| 28 |
+
|
| 29 |
+
# Vercel
|
| 30 |
+
.vercel
|
| 31 |
+
|
| 32 |
+
# Python
|
| 33 |
+
__pycache__/
|
| 34 |
+
*.py[cod]
|
| 35 |
+
*$py.class
|
| 36 |
+
*.so
|
| 37 |
+
.Python
|
| 38 |
+
env/
|
| 39 |
+
venv/
|
| 40 |
+
ENV/
|
| 41 |
+
.venv
|
| 42 |
+
.conda-py311/
|
| 43 |
+
.venv311/
|
| 44 |
+
|
| 45 |
+
# ML specific
|
| 46 |
+
ml/models/
|
| 47 |
+
ml/models_pretrained/
|
| 48 |
+
ml/data/
|
| 49 |
+
ml/logs/
|
| 50 |
+
uploads/
|
| 51 |
+
# Runtime prediction audit log (the inference service creates data/ at startup)
|
| 52 |
+
/data/
|
| 53 |
+
*.h5
|
| 54 |
+
*.keras
|
| 55 |
+
*.tflite
|
| 56 |
+
|
| 57 |
+
# Training artifacts / release archives (large binaries — distribute out-of-band)
|
| 58 |
+
cropintel-models*.zip
|
| 59 |
+
nohup.out
|
| 60 |
+
|
| 61 |
+
# TS incremental build cache
|
| 62 |
+
tsconfig.tsbuildinfo
|
Dockerfile
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CropIntel: Next.js + Python inference (no Kaggle needed if CROPINTEL_MODELS_URL is set)
|
| 2 |
+
FROM python:3.11-slim-bookworm
|
| 3 |
+
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
ca-certificates curl \
|
| 6 |
+
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
| 7 |
+
&& apt-get install -y --no-install-recommends nodejs \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
COPY package.json package-lock.json* ./
|
| 13 |
+
RUN npm ci
|
| 14 |
+
|
| 15 |
+
COPY ml/requirements-inference.txt ml/
|
| 16 |
+
RUN pip install --no-cache-dir -r ml/requirements-inference.txt supervisor
|
| 17 |
+
|
| 18 |
+
COPY . .
|
| 19 |
+
|
| 20 |
+
# NEXT_PUBLIC_* values are inlined into the client bundle at build time, so the
|
| 21 |
+
# Maps key must be present during `npm run build` (not just at runtime). Passed
|
| 22 |
+
# as a build arg — empty by default, which simply disables the outbreak map.
|
| 23 |
+
# On Hugging Face Spaces, set it as a build-time Variable; with compose, see the
|
| 24 |
+
# build.args block in docker-compose.prod.yml.
|
| 25 |
+
ARG NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=""
|
| 26 |
+
ENV NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=$NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
|
| 27 |
+
|
| 28 |
+
RUN npm run build
|
| 29 |
+
|
| 30 |
+
RUN chmod +x docker/entrypoint.sh
|
| 31 |
+
|
| 32 |
+
# Some hosts (e.g. Hugging Face Spaces) run the container as a non-root UID 1000.
|
| 33 |
+
# The image is built as root, so make the dirs written at runtime — model fetch,
|
| 34 |
+
# prediction audit log, and the Next.js server cache — writable by any user.
|
| 35 |
+
RUN mkdir -p ml/models data \
|
| 36 |
+
&& chmod -R 777 ml/models data .next
|
| 37 |
+
|
| 38 |
+
ENV NODE_ENV=production
|
| 39 |
+
ENV PYTHONUNBUFFERED=1
|
| 40 |
+
# Inference service bind address. Default 127.0.0.1 keeps it internal (the
|
| 41 |
+
# all-in-one VPS deploy proxies it via the Next.js app). Set to 0.0.0.0 to
|
| 42 |
+
# expose it directly — e.g. on Hugging Face as a backend for a Vercel frontend.
|
| 43 |
+
ENV INFERENCE_BIND_HOST=127.0.0.1
|
| 44 |
+
|
| 45 |
+
EXPOSE 3050
|
| 46 |
+
|
| 47 |
+
ENTRYPOINT ["/app/docker/entrypoint.sh"]
|
| 48 |
+
CMD ["supervisord", "-c", "/app/docker/supervisord.conf"]
|
Dockerfile.ml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ML training / inference only (Next.js app is not in this image).
|
| 2 |
+
FROM python:3.11-slim-bookworm
|
| 3 |
+
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
libgomp1 \
|
| 6 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 7 |
+
|
| 8 |
+
WORKDIR /app
|
| 9 |
+
|
| 10 |
+
COPY ml/requirements.txt /app/ml/requirements.txt
|
| 11 |
+
RUN pip install --no-cache-dir -r /app/ml/requirements.txt
|
| 12 |
+
|
| 13 |
+
COPY ml /app/ml
|
| 14 |
+
|
| 15 |
+
ENV PYTHONPATH=/app
|
| 16 |
+
ENV TF_CPP_MIN_LOG_LEVEL=2
|
| 17 |
+
|
| 18 |
+
# Override with e.g. docker compose run ... python -m ml.training.train_crop --crop corn
|
| 19 |
+
CMD ["python", "-m", "ml.scripts.create_synthetic_dataset", "--help"]
|
GOOGLE_MAPS_SETUP.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Google Maps API Setup Guide
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
The outbreak reporting map now uses Google Maps API for a more accurate and interactive map experience.
|
| 5 |
+
|
| 6 |
+
## Getting Your Google Maps API Key
|
| 7 |
+
|
| 8 |
+
### Step 1: Create a Google Cloud Project
|
| 9 |
+
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
| 10 |
+
2. Click "Select a project" → "New Project"
|
| 11 |
+
3. Enter a project name (e.g., "CropIntel")
|
| 12 |
+
4. Click "Create"
|
| 13 |
+
|
| 14 |
+
### Step 2: Enable Google Maps JavaScript API
|
| 15 |
+
1. In your project, go to "APIs & Services" → "Library"
|
| 16 |
+
2. Search for "Maps JavaScript API"
|
| 17 |
+
3. Click on it and press "Enable"
|
| 18 |
+
|
| 19 |
+
### Step 3: Create API Key
|
| 20 |
+
1. Go to "APIs & Services" → "Credentials"
|
| 21 |
+
2. Click "Create Credentials" → "API Key"
|
| 22 |
+
3. Copy your API key
|
| 23 |
+
4. **Important**: Click "Restrict Key" to secure it:
|
| 24 |
+
- Under "Application restrictions", select **"Websites"**
|
| 25 |
+
- Click "Add an item" and add your domains:
|
| 26 |
+
- `http://localhost:3040/*` (for development on port 3040)
|
| 27 |
+
- `http://localhost:*/*` (for all local ports - optional)
|
| 28 |
+
- `https://yourdomain.com/*` (for production - replace with your domain)
|
| 29 |
+
- Under "API restrictions", select "Restrict key"
|
| 30 |
+
- Choose "Maps JavaScript API"
|
| 31 |
+
- Click "Save"
|
| 32 |
+
|
| 33 |
+
## Configuration
|
| 34 |
+
|
| 35 |
+
### Step 4: Add API Key to Your Project
|
| 36 |
+
1. Create or edit `.env.local` in your project root:
|
| 37 |
+
```bash
|
| 38 |
+
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_api_key_here
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
2. Replace `your_api_key_here` with your actual API key
|
| 42 |
+
|
| 43 |
+
### Step 5: Restart Development Server
|
| 44 |
+
```bash
|
| 45 |
+
# Stop the current server (Ctrl+C)
|
| 46 |
+
# Then restart:
|
| 47 |
+
npm run dev -- -p 3040
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
## Features
|
| 51 |
+
|
| 52 |
+
✅ **Interactive Google Maps** - Full Google Maps functionality
|
| 53 |
+
✅ **Click to Report** - Click anywhere on the map to report outbreaks
|
| 54 |
+
✅ **Custom Markers** - Color-coded markers based on severity
|
| 55 |
+
✅ **Info Windows** - Click markers to see outbreak details
|
| 56 |
+
✅ **Zoom & Pan** - Full map navigation controls
|
| 57 |
+
✅ **Accurate Locations** - Real geographic coordinates
|
| 58 |
+
|
| 59 |
+
## Fallback Behavior
|
| 60 |
+
|
| 61 |
+
If no API key is configured:
|
| 62 |
+
- The map will show an error message
|
| 63 |
+
- You'll be prompted to add the API key
|
| 64 |
+
- The app will still function, but the map won't load
|
| 65 |
+
|
| 66 |
+
## Cost Information
|
| 67 |
+
|
| 68 |
+
Google Maps JavaScript API has a free tier:
|
| 69 |
+
- **$200 free credit per month**
|
| 70 |
+
- After free credit: ~$7 per 1,000 map loads
|
| 71 |
+
- For most development/testing: **FREE**
|
| 72 |
+
|
| 73 |
+
## Security Notes
|
| 74 |
+
|
| 75 |
+
⚠️ **Important**:
|
| 76 |
+
- Never commit your API key to git (`.env.local` is already in `.gitignore`)
|
| 77 |
+
- Always restrict your API key to specific domains
|
| 78 |
+
- Use different keys for development and production
|
| 79 |
+
- Monitor your API usage in Google Cloud Console
|
| 80 |
+
|
| 81 |
+
## Troubleshooting
|
| 82 |
+
|
| 83 |
+
### Map Not Loading?
|
| 84 |
+
1. Check that `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` is set in `.env.local`
|
| 85 |
+
2. Restart the dev server after adding the key
|
| 86 |
+
3. Verify the API key is not restricted incorrectly
|
| 87 |
+
4. Check browser console for specific error messages
|
| 88 |
+
|
| 89 |
+
### "This page can't load Google Maps correctly"?
|
| 90 |
+
- Your API key might not be restricted correctly
|
| 91 |
+
- Make sure `http://localhost:3040/*` is added under "Websites" restrictions
|
| 92 |
+
- Check that "Maps JavaScript API" is enabled
|
| 93 |
+
- Verify you're using `http://` (not `https://`) for localhost
|
| 94 |
+
|
| 95 |
+
### API Key Invalid?
|
| 96 |
+
- Verify you copied the full key (no spaces)
|
| 97 |
+
- Check that the API is enabled in Google Cloud Console
|
| 98 |
+
- Ensure billing is enabled (free tier still requires billing account)
|
| 99 |
+
|
| 100 |
+
## Need Help?
|
| 101 |
+
|
| 102 |
+
Check the [Google Maps JavaScript API Documentation](https://developers.google.com/maps/documentation/javascript)
|
ML_Architecture_Explanation.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CropIntel Machine Learning Architecture
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
CropIntel employs a sophisticated deep learning pipeline built on EfficientNet architecture to achieve accurate, real-time crop disease detection. The system processes crop leaf images through multiple stages, transforming raw pixel data into actionable disease diagnoses with confidence scores.
|
| 6 |
+
|
| 7 |
+
## Architecture Pipeline
|
| 8 |
+
|
| 9 |
+
### Stage 1: Image Acquisition & Input Processing
|
| 10 |
+
|
| 11 |
+
**Input Format**: The system accepts crop leaf images in common formats (JPEG, PNG) uploaded by farmers through a web interface. Images can vary in resolution, quality, and environmental conditions (field photos, laboratory images, different lighting conditions).
|
| 12 |
+
|
| 13 |
+
**Initial Validation**: Upon upload, the system performs basic validation checks including file format verification, size constraints (typically 5-10MB maximum), and basic image integrity checks to ensure the file is not corrupted.
|
| 14 |
+
|
| 15 |
+
### Stage 2: Preprocessing Pipeline
|
| 16 |
+
|
| 17 |
+
The preprocessing stage standardizes input images for optimal model performance:
|
| 18 |
+
|
| 19 |
+
**Format Normalization**: All images are converted to a standardized RGB format, ensuring consistent color space representation regardless of input format variations.
|
| 20 |
+
|
| 21 |
+
**Resolution Standardization**: Images are resized to 224×224 pixels, the standard input size for EfficientNet models. The resizing process maintains aspect ratio using intelligent cropping or padding to prevent distortion of critical disease features.
|
| 22 |
+
|
| 23 |
+
**Color Normalization**: RGB pixel values are normalized from the standard 0-255 range to a 0-1 floating-point range. This normalization improves neural network training stability and convergence speed.
|
| 24 |
+
|
| 25 |
+
**Quality Enhancement**: The preprocessing pipeline applies subtle enhancements including contrast adjustment, noise reduction, and brightness normalization to improve feature visibility while preserving authentic disease characteristics.
|
| 26 |
+
|
| 27 |
+
**Data Augmentation (Training Phase)**: During model training, additional augmentation techniques are applied including random rotation (±15°), horizontal flipping, brightness/contrast variation, and color jittering to improve model generalization across diverse real-world conditions.
|
| 28 |
+
|
| 29 |
+
### Stage 3: EfficientNet-B0 Convolutional Neural Network
|
| 30 |
+
|
| 31 |
+
**Architecture Selection**: EfficientNet-B0 was selected as the core architecture due to its superior balance between accuracy and computational efficiency. The model achieves state-of-the-art performance while maintaining inference speeds suitable for real-time web applications.
|
| 32 |
+
|
| 33 |
+
**Transfer Learning Foundation**: The model leverages transfer learning, initialized with weights pre-trained on ImageNet (1.4 million images, 1000 classes). This foundation provides robust feature extraction capabilities learned from diverse visual patterns, which are then fine-tuned for agricultural disease detection.
|
| 34 |
+
|
| 35 |
+
**Feature Extraction Layers**: The convolutional backbone consists of multiple Mobile Inverted Bottleneck Convolution (MBConv) blocks that progressively extract hierarchical features:
|
| 36 |
+
- **Early Layers (Blocks 1-3)**: Detect low-level features including edges, textures, and basic color patterns
|
| 37 |
+
- **Middle Layers (Blocks 4-6)**: Identify complex patterns such as leaf structures, lesion shapes, and discoloration patterns
|
| 38 |
+
- **Deep Layers (Blocks 7-9)**: Recognize high-level semantic features including disease-specific characteristics, symptom combinations, and spatial relationships
|
| 39 |
+
|
| 40 |
+
**Global Average Pooling**: After feature extraction, spatial dimensions are reduced through Global Average Pooling, which converts the feature maps into a fixed-size vector while preserving critical feature information. This operation reduces model parameters and prevents overfitting.
|
| 41 |
+
|
| 42 |
+
**Classification Head**: The pooled features pass through fully connected layers that map extracted features to disease categories. The final layer uses softmax activation to produce a probability distribution across all possible disease classes for the specific crop type.
|
| 43 |
+
|
| 44 |
+
**TensorFlow Lite Conversion**: After training, Keras models are converted to TensorFlow Lite format using TFLiteConverter. This conversion optimizes the model for production deployment by:
|
| 45 |
+
- **Quantization**: Reducing model size through 8-bit integer quantization (optional)
|
| 46 |
+
- **Optimization**: Applying graph optimizations for faster inference
|
| 47 |
+
- **Mobile/Edge Ready**: Enabling deployment on mobile devices and edge computing platforms
|
| 48 |
+
- **Reduced Memory**: Smaller model footprint (~5-10MB vs ~20MB for Keras format)
|
| 49 |
+
- **Faster Inference**: Optimized operations for production environments
|
| 50 |
+
|
| 51 |
+
### Stage 4: Crop-Specific Model Routing
|
| 52 |
+
|
| 53 |
+
**Multi-Model Architecture**: Rather than a single universal model, CropIntel employs crop-specific models (one each for corn, soybean, wheat, and rice). This specialization allows each model to focus on disease patterns specific to that crop, improving accuracy.
|
| 54 |
+
|
| 55 |
+
**Model Selection**: Based on user-selected crop type, the system routes the preprocessed image to the appropriate specialized TensorFlow Lite model. The TFLitePredictor class loads the corresponding .tflite model file and uses the TensorFlow Lite Interpreter for inference, ensuring optimal disease detection for each agricultural context.
|
| 56 |
+
|
| 57 |
+
**TensorFlow Lite Inference**: The prediction process uses TensorFlow Lite's optimized interpreter:
|
| 58 |
+
- **Model Loading**: TFLite models are loaded into memory using `tf.lite.Interpreter`
|
| 59 |
+
- **Input Tensor Setup**: Preprocessed images are set as input tensors with proper dtype conversion
|
| 60 |
+
- **Invoke Inference**: The interpreter's `invoke()` method executes optimized inference operations
|
| 61 |
+
- **Output Extraction**: Probability distributions are extracted from output tensors
|
| 62 |
+
- **Fallback Support**: System can fall back to Keras models if TFLite models are unavailable
|
| 63 |
+
|
| 64 |
+
### Stage 5: Post-Processing & Confidence Scoring
|
| 65 |
+
|
| 66 |
+
**Confidence Calculation**: The model outputs probability scores for each disease class. The maximum probability becomes the prediction confidence score, ranging from 0% to 100%.
|
| 67 |
+
|
| 68 |
+
**Threshold Filtering**: Predictions below a minimum confidence threshold (typically 60%) are flagged as uncertain. Low-confidence predictions trigger user warnings and recommendations to capture additional images or consult experts.
|
| 69 |
+
|
| 70 |
+
**Health Status Determination**: If no disease class exceeds the confidence threshold, the crop is classified as "healthy." This binary health assessment provides immediate actionable information.
|
| 71 |
+
|
| 72 |
+
**Severity Assessment**: Based on the detected disease and confidence level, the system assigns severity ratings (low, medium, high) to guide treatment urgency and resource allocation.
|
| 73 |
+
|
| 74 |
+
### Stage 6: Result Generation & Output
|
| 75 |
+
|
| 76 |
+
**Structured Output**: The system generates comprehensive results including:
|
| 77 |
+
- **Disease Identification**: Specific disease name with scientific accuracy
|
| 78 |
+
- **Confidence Percentage**: Numerical confidence score (e.g., "87% confident")
|
| 79 |
+
- **Health Status**: Binary healthy/diseased classification
|
| 80 |
+
- **Treatment Recommendations**: Evidence-based treatment options specific to the detected disease
|
| 81 |
+
- **Prevention Strategies**: Long-term prevention measures to reduce future risk
|
| 82 |
+
- **Severity Level**: Risk assessment (low/medium/high) for treatment prioritization
|
| 83 |
+
|
| 84 |
+
**Response Time**: The entire pipeline, from image upload to result display, completes in under 2 seconds, enabling real-time field decision-making.
|
| 85 |
+
|
| 86 |
+
## Model Training & Optimization
|
| 87 |
+
|
| 88 |
+
**Dataset Composition**: Models are trained on curated datasets containing thousands of labeled crop disease images. Datasets are balanced across disease classes to prevent model bias toward common conditions.
|
| 89 |
+
|
| 90 |
+
**Training Methodology**: Fine-tuning employs a two-phase approach: (1) freezing early layers to preserve general feature extraction, (2) training final layers with agricultural disease data. This approach leverages ImageNet knowledge while adapting to domain-specific patterns.
|
| 91 |
+
|
| 92 |
+
**Hyperparameter Optimization**: Learning rates, batch sizes, and regularization parameters are tuned through systematic experimentation. Early stopping prevents overfitting by monitoring validation loss.
|
| 93 |
+
|
| 94 |
+
**Performance Metrics**: Models achieve 87-92% accuracy across crop types, with precision and recall balanced to minimize both false positives (unnecessary treatments) and false negatives (missed diseases).
|
| 95 |
+
|
| 96 |
+
## Technical Specifications
|
| 97 |
+
|
| 98 |
+
- **Framework**: TensorFlow Lite (TFLite) for production inference
|
| 99 |
+
- **Model Format**: Optimized TensorFlow Lite models (.tflite) converted from Keras models
|
| 100 |
+
- **Model Size**: EfficientNet-B0 (~5.3M parameters, ~5-10MB in TFLite format)
|
| 101 |
+
- **Input Dimensions**: 224×224×3 (RGB)
|
| 102 |
+
- **Inference Engine**: TensorFlow Lite Interpreter for optimized runtime performance
|
| 103 |
+
- **Inference Speed**: <2 seconds per image
|
| 104 |
+
- **Accuracy Range**: 87-92% depending on crop type
|
| 105 |
+
- **Supported Crops**: Corn, Soybean, Wheat, Rice (expandable)
|
| 106 |
+
|
| 107 |
+
## Innovation & Advantages
|
| 108 |
+
|
| 109 |
+
**Efficiency**: EfficientNet architecture provides superior accuracy-to-efficiency ratio compared to traditional CNNs, enabling real-time performance on standard hardware.
|
| 110 |
+
|
| 111 |
+
**Accessibility**: Optimized model size and inference speed make advanced AI accessible to farmers using basic smartphones and standard internet connections.
|
| 112 |
+
|
| 113 |
+
**Scalability**: Modular architecture allows easy addition of new crop types and diseases without retraining entire models.
|
| 114 |
+
|
| 115 |
+
**Reliability**: Transfer learning foundation provides robust feature extraction, while crop-specific fine-tuning ensures domain accuracy.
|
| 116 |
+
|
| 117 |
+
This architecture represents a production-ready, scalable solution that brings state-of-the-art AI capabilities to agricultural disease detection, balancing accuracy, speed, and accessibility for real-world deployment.
|
README copy.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CropIntel - AI-Powered Crop Disease Classification
|
| 2 |
+
|
| 3 |
+
A modern web application for detecting crop diseases using deep learning.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- 🌾 Support for multiple crops (Corn, Soybean, Wheat, Rice)
|
| 8 |
+
- 🤖 AI-powered disease detection using EfficientNet
|
| 9 |
+
- 📱 Modern, responsive web interface built with Next.js
|
| 10 |
+
- ⚡ Fast inference using TensorFlow Lite
|
| 11 |
+
- 🎨 Beautiful UI with Tailwind CSS
|
| 12 |
+
|
| 13 |
+
## Prerequisites
|
| 14 |
+
|
| 15 |
+
- Python 3.8+
|
| 16 |
+
- Node.js 18+
|
| 17 |
+
- npm or yarn
|
| 18 |
+
|
| 19 |
+
## Installation
|
| 20 |
+
|
| 21 |
+
### 1. Install Python Dependencies
|
| 22 |
+
|
| 23 |
+
```bash
|
| 24 |
+
pip install -r ml/requirements.txt
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
### 2. Install Node.js Dependencies
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
npm install
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
## Running the Application
|
| 34 |
+
|
| 35 |
+
### Development Mode
|
| 36 |
+
|
| 37 |
+
1. Start the Next.js development server:
|
| 38 |
+
|
| 39 |
+
```bash
|
| 40 |
+
npm run dev
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
2. Open [http://localhost:3000](http://localhost:3000) in your browser
|
| 44 |
+
|
| 45 |
+
### Production Mode
|
| 46 |
+
|
| 47 |
+
1. Build the application:
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
npm run build
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
2. Start the production server:
|
| 54 |
+
|
| 55 |
+
```bash
|
| 56 |
+
npm start
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
## Training Models
|
| 60 |
+
|
| 61 |
+
Before using the web app, you need to train models for the crops you want to classify:
|
| 62 |
+
|
| 63 |
+
```bash
|
| 64 |
+
# Train a single crop model
|
| 65 |
+
python3 -m ml.training.train_crop --crop corn --epochs 40
|
| 66 |
+
|
| 67 |
+
# Train all crops
|
| 68 |
+
python3 -m ml.training.train_all_crops --epochs 40
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
## Project Structure
|
| 72 |
+
|
| 73 |
+
```
|
| 74 |
+
cropintel/
|
| 75 |
+
├── app/ # Next.js app directory
|
| 76 |
+
│ ├── api/ # API routes
|
| 77 |
+
│ ├── globals.css # Global styles
|
| 78 |
+
│ ├── layout.tsx # Root layout
|
| 79 |
+
│ └── page.tsx # Home page
|
| 80 |
+
├── components/ # React components
|
| 81 |
+
│ ├── ImageUpload.tsx
|
| 82 |
+
│ ├── CropSelector.tsx
|
| 83 |
+
│ └── PredictionResults.tsx
|
| 84 |
+
├── ml/ # Machine learning code
|
| 85 |
+
│ ├── training/ # Training scripts
|
| 86 |
+
│ ├── inference/ # Inference modules
|
| 87 |
+
│ └── utils/ # Utilities
|
| 88 |
+
├── scripts/ # Utility scripts
|
| 89 |
+
│ └── predict.py # Python prediction script
|
| 90 |
+
└── package.json # Node.js dependencies
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
## Usage
|
| 94 |
+
|
| 95 |
+
1. Upload a crop leaf image
|
| 96 |
+
2. Select the crop type (Corn, Soybean, Wheat, or Rice)
|
| 97 |
+
3. Click "Analyze Disease"
|
| 98 |
+
4. View the prediction results with confidence scores
|
| 99 |
+
|
| 100 |
+
## Technologies
|
| 101 |
+
|
| 102 |
+
- **Frontend**: Next.js 14, React, TypeScript, Tailwind CSS
|
| 103 |
+
- **Backend**: Python, Flask (via API route)
|
| 104 |
+
- **ML**: TensorFlow, EfficientNet, TensorFlow Lite
|
| 105 |
+
- **Image Processing**: PIL/Pillow
|
| 106 |
+
|
| 107 |
+
## License
|
| 108 |
+
|
| 109 |
+
MIT
|
README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: CropIntel
|
| 3 |
+
emoji: 🌾
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 3050
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
<!-- The YAML block above configures the Hugging Face Space (Docker SDK,
|
| 12 |
+
public port 3050). Required by HF; ignored by GitHub. See docs/DEPLOYMENT.md. -->
|
| 13 |
+
|
| 14 |
+
# CropIntel
|
| 15 |
+
|
| 16 |
+
Crop leaf-disease classifier for 5 crops (corn, soybean, wheat, rice, tomato),
|
| 17 |
+
EfficientNetB0 → TensorFlow Lite, served behind a Next.js UI. One Docker
|
| 18 |
+
container runs the web app and a persistent Python inference service together.
|
| 19 |
+
|
| 20 |
+
## Quick start (run the whole thing)
|
| 21 |
+
|
| 22 |
+
You do **not** need Kaggle, training, or any model files — the trained models
|
| 23 |
+
(~38 MB) are fetched automatically from the GitHub Release on first start.
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
git clone https://github.com/rakshithj09/CropIntel.git
|
| 27 |
+
cd CropIntel
|
| 28 |
+
docker compose -f docker-compose.prod.yml up -d --build
|
| 29 |
+
curl -fsS http://localhost:3050/api/health # {"web":"ok","inference":{"ready":true,...}}
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
Open [http://localhost:3050](http://localhost:3050). That's it.
|
| 33 |
+
|
| 34 |
+
Optional environment (drop a `.env` next to the compose file):
|
| 35 |
+
|
| 36 |
+
```bash
|
| 37 |
+
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=... # only for the outbreak map
|
| 38 |
+
CROPINTEL_ADMIN_TOKEN=$(openssl rand -hex 16) # only to guard POST /admin/reload
|
| 39 |
+
CROPINTEL_MODELS_URL=... # override the default v1 model bundle
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
For a real domain + TLS, monitoring, and model promotion/rollback, see
|
| 43 |
+
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
| 44 |
+
|
| 45 |
+
## Local development (no Docker)
|
| 46 |
+
|
| 47 |
+
The web app forwards predictions to the inference service, so run both:
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
# 1) fetch models once (into ml/models/, gitignored)
|
| 51 |
+
pip install -r ml/requirements-inference.txt
|
| 52 |
+
export CROPINTEL_MODELS_URL='https://github.com/rakshithj09/CropIntel/releases/download/v1/cropintel-models-mobile.zip'
|
| 53 |
+
python3 -m ml.scripts.fetch_models
|
| 54 |
+
|
| 55 |
+
# 2) start the inference service (terminal A)
|
| 56 |
+
python3 -m uvicorn ml.serve.inference_app:app --host 127.0.0.1 --port 8000
|
| 57 |
+
|
| 58 |
+
# 3) start the web app (terminal B)
|
| 59 |
+
npm install && npm run dev
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
Open [http://localhost:3050](http://localhost:3050). The UI calls `/api/predict`,
|
| 63 |
+
which forwards to the inference service at `INFERENCE_URL` (default
|
| 64 |
+
`http://127.0.0.1:8000`).
|
| 65 |
+
|
| 66 |
+
## Train it yourself (needs Kaggle data)
|
| 67 |
+
|
| 68 |
+
See [ml/README.md](ml/README.md) for the Kaggle API setup and training scripts
|
| 69 |
+
(`pip install -r ml/requirements.txt`). Models are gated on an **external**
|
| 70 |
+
(out-of-distribution) eval before promotion — see
|
| 71 |
+
`ml/scripts/test_external.py` and `ml/scripts/promote_model.py`.
|
| 72 |
+
|
| 73 |
+
## Maintainer: ship updated models
|
| 74 |
+
|
| 75 |
+
After training/promoting, repackage and replace the release bundle:
|
| 76 |
+
|
| 77 |
+
```bash
|
| 78 |
+
python3 -m ml.scripts.package_models --tflite-only -o cropintel-models-mobile.zip
|
| 79 |
+
gh release upload v1 cropintel-models-mobile.zip -R rakshithj09/CropIntel --clobber
|
| 80 |
+
# on a running server: rm ml/models/.cropintel-fetch-ok && docker compose -f docker-compose.prod.yml restart
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
## Project layout
|
| 84 |
+
|
| 85 |
+
- `app/` — Next.js UI + `/api/predict` (forwards to the inference service) + `/api/health`
|
| 86 |
+
- `ml/serve/inference_app.py` — FastAPI inference service (loads every crop model once)
|
| 87 |
+
- `ml/` — training (`training/`), predictors (`inference/`), config, scripts
|
| 88 |
+
- `docker-compose.prod.yml`, `docker/`, `docs/DEPLOYMENT.md` — production deploy
|
| 89 |
+
- `tests/` — pytest suite (`.github/workflows/ci.yml` runs web + Python checks)
|
| 90 |
+
|
| 91 |
+
## License
|
| 92 |
+
|
| 93 |
+
See repository.
|
SECURITY_CHANGES_SUMMARY.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Security Hardening - Implementation Complete ✅
|
| 2 |
+
|
| 3 |
+
## Summary
|
| 4 |
+
|
| 5 |
+
Security measures follow OWASP best practices. The application is hardened against common vulnerabilities while maintaining backward compatibility for core features.
|
| 6 |
+
|
| 7 |
+
## ✅ Completed Security Measures
|
| 8 |
+
|
| 9 |
+
### 1. Rate Limiting
|
| 10 |
+
- **File**: `lib/security/rateLimiter.ts`
|
| 11 |
+
- IP-based rate limiting for API routes
|
| 12 |
+
- Configurable thresholds (e.g. 20/min for predictions)
|
| 13 |
+
- HTTP 429 responses with Retry-After headers
|
| 14 |
+
- Rate limit status headers in responses
|
| 15 |
+
|
| 16 |
+
### 2. Input Validation & Sanitization
|
| 17 |
+
- **File**: `lib/security/validation.ts`
|
| 18 |
+
- Zod schema-based validation
|
| 19 |
+
- Strict type checking
|
| 20 |
+
- File upload validation (size, type, content)
|
| 21 |
+
- Filename sanitization (path traversal prevention)
|
| 22 |
+
- Crop type whitelist validation
|
| 23 |
+
|
| 24 |
+
### 3. File Upload Security
|
| 25 |
+
- **File**: `app/api/predict/route.ts`
|
| 26 |
+
- 10MB file size limit
|
| 27 |
+
- MIME type whitelist validation
|
| 28 |
+
- Filename sanitization
|
| 29 |
+
- Temporary file cleanup
|
| 30 |
+
- Server-side content validation
|
| 31 |
+
|
| 32 |
+
### 4. Security Headers
|
| 33 |
+
- **Files**:
|
| 34 |
+
- `lib/security/headers.ts`
|
| 35 |
+
- `middleware.ts` (applies to all routes)
|
| 36 |
+
- Content-Security-Policy
|
| 37 |
+
- X-Frame-Options
|
| 38 |
+
- X-Content-Type-Options
|
| 39 |
+
- Referrer-Policy
|
| 40 |
+
- Permissions-Policy
|
| 41 |
+
- Strict-Transport-Security (production)
|
| 42 |
+
|
| 43 |
+
### 5. Error Handling
|
| 44 |
+
- Generic error messages (no information disclosure)
|
| 45 |
+
- Detailed errors logged server-side only
|
| 46 |
+
- Secure error responses
|
| 47 |
+
|
| 48 |
+
## 📁 Security-related files
|
| 49 |
+
|
| 50 |
+
```
|
| 51 |
+
lib/security/
|
| 52 |
+
├── rateLimiter.ts # Rate limiting middleware
|
| 53 |
+
├── validation.ts # Input validation schemas
|
| 54 |
+
└── headers.ts # Security headers
|
| 55 |
+
|
| 56 |
+
middleware.ts # Security headers middleware
|
| 57 |
+
SECURITY_IMPLEMENTATION.md # Detailed documentation
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
## 🔄 Modified core files
|
| 61 |
+
|
| 62 |
+
- `app/api/predict/route.ts` — Security measures for ML prediction uploads
|
| 63 |
+
- `next.config.js` — Disabled X-Powered-By header (if configured)
|
| 64 |
+
|
| 65 |
+
## 🔐 Environment variables
|
| 66 |
+
|
| 67 |
+
**`.env.local` example** (see `.env.local.example`):
|
| 68 |
+
|
| 69 |
+
```bash
|
| 70 |
+
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_key
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
## ✅ OWASP alignment
|
| 74 |
+
|
| 75 |
+
| Requirement | Status | Implementation |
|
| 76 |
+
|------------|--------|----------------|
|
| 77 |
+
| Rate Limiting | ✅ | IP-based, configurable thresholds |
|
| 78 |
+
| Input Validation | ✅ | Schema-based, strict validation |
|
| 79 |
+
| File Upload Security | ✅ | Size limits, type validation, sanitization |
|
| 80 |
+
| Security Headers | ✅ | CSP, X-Frame-Options, etc. |
|
| 81 |
+
| Error Handling | ✅ | No information disclosure |
|
| 82 |
+
|
| 83 |
+
## 🧪 Testing
|
| 84 |
+
|
| 85 |
+
1. **Rate limiting**: Send 21+ rapid requests to `/api/predict`
|
| 86 |
+
2. **Input validation**: Send invalid crop type or oversized file
|
| 87 |
+
|
| 88 |
+
## 📚 Documentation
|
| 89 |
+
|
| 90 |
+
- **SECURITY_IMPLEMENTATION.md**: Security documentation
|
| 91 |
+
- **SECURITY.md**: Security analysis (if present)
|
| 92 |
+
|
| 93 |
+
## 🚀 Next steps
|
| 94 |
+
|
| 95 |
+
1. Test rate limiting and validation
|
| 96 |
+
2. Review CSP for your deployment (CDNs, analytics, etc.)
|
| 97 |
+
3. Consider Redis for rate limiting in production (multi-instance)
|
| 98 |
+
|
| 99 |
+
---
|
| 100 |
+
|
| 101 |
+
**Status**: ✅ Complete
|
app/api/health/route.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Health API Route
|
| 3 |
+
*
|
| 4 |
+
* Single URL for the compose healthcheck, uptime monitors, and humans.
|
| 5 |
+
* Reports web-tier liveness plus the inference service's readiness
|
| 6 |
+
* (per-crop model load status from /readyz).
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import { createSecureResponse } from '@/lib/security/headers'
|
| 10 |
+
|
| 11 |
+
const INFERENCE_URL = process.env.INFERENCE_URL || 'http://127.0.0.1:8000'
|
| 12 |
+
|
| 13 |
+
export async function GET() {
|
| 14 |
+
let inference: any = null
|
| 15 |
+
let healthy = false
|
| 16 |
+
|
| 17 |
+
try {
|
| 18 |
+
const upstream = await fetch(`${INFERENCE_URL}/readyz`, {
|
| 19 |
+
signal: AbortSignal.timeout(5_000),
|
| 20 |
+
cache: 'no-store',
|
| 21 |
+
})
|
| 22 |
+
inference = await upstream.json()
|
| 23 |
+
healthy = upstream.ok
|
| 24 |
+
} catch {
|
| 25 |
+
inference = { ready: false, error: 'inference service unreachable' }
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
return createSecureResponse(
|
| 29 |
+
{ web: 'ok', inference },
|
| 30 |
+
healthy ? 200 : 503
|
| 31 |
+
)
|
| 32 |
+
}
|
app/api/predict/route.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Prediction API Route
|
| 3 |
+
*
|
| 4 |
+
* Secure API endpoint for crop disease prediction.
|
| 5 |
+
* Implements comprehensive security measures following OWASP best practices.
|
| 6 |
+
*
|
| 7 |
+
* Security Features:
|
| 8 |
+
* - Rate limiting (IP-based)
|
| 9 |
+
* - Input validation and sanitization
|
| 10 |
+
* - File upload security (size limits, type validation)
|
| 11 |
+
* - Security headers
|
| 12 |
+
* - Secure error handling
|
| 13 |
+
*
|
| 14 |
+
* OWASP Compliance:
|
| 15 |
+
* - A01:2021 (Broken Access Control) - Rate limiting
|
| 16 |
+
* - A03:2021 (Injection) - Input validation
|
| 17 |
+
* - A05:2021 (Security Misconfiguration) - Security headers
|
| 18 |
+
* - A07:2021 (Identification and Authentication Failures) - Input validation
|
| 19 |
+
*
|
| 20 |
+
* Inference is served by the persistent Python service (ml/serve/inference_app.py)
|
| 21 |
+
* over localhost HTTP — models stay loaded in memory between requests.
|
| 22 |
+
*/
|
| 23 |
+
|
| 24 |
+
import { NextRequest } from 'next/server'
|
| 25 |
+
import { rateLimit, getRateLimitHeaders } from '@/lib/security/rateLimiter'
|
| 26 |
+
import { validatePredictionRequest } from '@/lib/security/validation'
|
| 27 |
+
import { createSecureResponse, addSecurityHeaders } from '@/lib/security/headers'
|
| 28 |
+
import { ZodError } from 'zod'
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* Maximum file size: 10MB
|
| 32 |
+
* Prevents DoS attacks via large file uploads
|
| 33 |
+
*/
|
| 34 |
+
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
| 35 |
+
|
| 36 |
+
/**
|
| 37 |
+
* Allowed image MIME types (whitelist approach)
|
| 38 |
+
* Prevents malicious file uploads
|
| 39 |
+
*/
|
| 40 |
+
const ALLOWED_MIME_TYPES = [
|
| 41 |
+
'image/jpeg',
|
| 42 |
+
'image/jpg',
|
| 43 |
+
'image/png',
|
| 44 |
+
'image/webp',
|
| 45 |
+
'image/gif',
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
/** Base URL of the Python inference service (never exposed publicly). */
|
| 49 |
+
const INFERENCE_URL = process.env.INFERENCE_URL || 'http://127.0.0.1:8000'
|
| 50 |
+
|
| 51 |
+
/** Upstream timeout — model inference is fast; this guards a hung service. */
|
| 52 |
+
const INFERENCE_TIMEOUT_MS = 30_000
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* Validate file content by checking MIME type
|
| 56 |
+
* Additional security layer beyond client-side validation
|
| 57 |
+
*
|
| 58 |
+
* @param file - File object to validate
|
| 59 |
+
* @returns true if file is valid image, false otherwise
|
| 60 |
+
*/
|
| 61 |
+
function validateFileContent(file: File): boolean {
|
| 62 |
+
// Check MIME type against whitelist
|
| 63 |
+
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
|
| 64 |
+
return false
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// Check file size
|
| 68 |
+
if (file.size > MAX_FILE_SIZE) {
|
| 69 |
+
return false
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// Check file is not empty
|
| 73 |
+
if (file.size === 0) {
|
| 74 |
+
return false
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
return true
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
export async function POST(request: NextRequest) {
|
| 81 |
+
// ========== RATE LIMITING ==========
|
| 82 |
+
// Apply rate limiting before processing request
|
| 83 |
+
// OWASP: Fail securely by blocking excessive requests
|
| 84 |
+
const rateLimitResponse = rateLimit(request, '/api/predict')
|
| 85 |
+
if (rateLimitResponse) {
|
| 86 |
+
return addSecurityHeaders(rateLimitResponse)
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
try {
|
| 90 |
+
// ========== INPUT VALIDATION ==========
|
| 91 |
+
// Parse and validate form data using schema-based validation
|
| 92 |
+
// OWASP: Prevents injection attacks via strict validation
|
| 93 |
+
const formData = await request.formData()
|
| 94 |
+
|
| 95 |
+
let validatedData
|
| 96 |
+
try {
|
| 97 |
+
validatedData = await validatePredictionRequest(formData)
|
| 98 |
+
} catch (error) {
|
| 99 |
+
// Handle validation errors gracefully
|
| 100 |
+
if (error instanceof ZodError) {
|
| 101 |
+
const errorMessages = error.issues.map((e) => e.message).join(', ')
|
| 102 |
+
return createSecureResponse(
|
| 103 |
+
{ error: `Validation failed: ${errorMessages}` },
|
| 104 |
+
400
|
| 105 |
+
)
|
| 106 |
+
}
|
| 107 |
+
throw error // Re-throw unexpected errors
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
const { image, crop } = validatedData
|
| 111 |
+
|
| 112 |
+
// ========== FILE CONTENT VALIDATION ==========
|
| 113 |
+
// Additional server-side validation beyond schema validation
|
| 114 |
+
// OWASP: Defense in depth - multiple validation layers
|
| 115 |
+
if (!validateFileContent(image)) {
|
| 116 |
+
return createSecureResponse(
|
| 117 |
+
{
|
| 118 |
+
error: 'Invalid file. Must be a valid image (JPEG, PNG, WebP, GIF) under 10MB.',
|
| 119 |
+
},
|
| 120 |
+
400
|
| 121 |
+
)
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// ========== INFERENCE SERVICE CALL ==========
|
| 125 |
+
// Forward the validated upload to the persistent inference service.
|
| 126 |
+
const upstreamForm = new FormData()
|
| 127 |
+
upstreamForm.append('image', image)
|
| 128 |
+
upstreamForm.append('crop', crop)
|
| 129 |
+
|
| 130 |
+
let upstream: Response
|
| 131 |
+
try {
|
| 132 |
+
upstream = await fetch(`${INFERENCE_URL}/predict`, {
|
| 133 |
+
method: 'POST',
|
| 134 |
+
body: upstreamForm,
|
| 135 |
+
signal: AbortSignal.timeout(INFERENCE_TIMEOUT_MS),
|
| 136 |
+
})
|
| 137 |
+
} catch (error) {
|
| 138 |
+
// Service down or timed out — operators should check the inference process.
|
| 139 |
+
console.error('Inference service unreachable:', error)
|
| 140 |
+
return createSecureResponse(
|
| 141 |
+
{ error: 'Prediction service unavailable. Please try again shortly.' },
|
| 142 |
+
503
|
| 143 |
+
)
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
let result: any
|
| 147 |
+
try {
|
| 148 |
+
result = await upstream.json()
|
| 149 |
+
} catch {
|
| 150 |
+
console.error('Inference service returned non-JSON, status:', upstream.status)
|
| 151 |
+
return createSecureResponse(
|
| 152 |
+
{ error: 'Prediction failed. Please try again later.' },
|
| 153 |
+
500
|
| 154 |
+
)
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
if (!upstream.ok) {
|
| 158 |
+
const errorMessage: string = result?.error || 'Prediction failed'
|
| 159 |
+
const msg = errorMessage.toLowerCase()
|
| 160 |
+
|
| 161 |
+
// User-actionable image problems (quality checks) — pass through as-is.
|
| 162 |
+
if (
|
| 163 |
+
msg.includes('retake the image') ||
|
| 164 |
+
msg.includes('clear plant leaf') ||
|
| 165 |
+
msg.includes('appears blurry')
|
| 166 |
+
) {
|
| 167 |
+
return createSecureResponse({ error: errorMessage }, 400)
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
// Model-not-ready — tell operators to train/fetch models.
|
| 171 |
+
if (msg.includes('no trained models found') || msg.includes('model not found')) {
|
| 172 |
+
return createSecureResponse(
|
| 173 |
+
{ error: 'Model not ready. Please train or install a model for this crop before running analysis.' },
|
| 174 |
+
503
|
| 175 |
+
)
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
console.error('Unhandled inference error:', upstream.status, errorMessage)
|
| 179 |
+
return createSecureResponse(
|
| 180 |
+
{ error: 'Prediction failed. Please try again later.' },
|
| 181 |
+
upstream.status >= 500 ? 500 : 400
|
| 182 |
+
)
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
// ========== SUCCESS RESPONSE ==========
|
| 186 |
+
// Return result with security headers and rate limit info
|
| 187 |
+
const response = createSecureResponse(result, 200)
|
| 188 |
+
|
| 189 |
+
// Add rate limit headers to successful response
|
| 190 |
+
const rateLimitHeaders = getRateLimitHeaders(request, '/api/predict')
|
| 191 |
+
Object.entries(rateLimitHeaders).forEach(([key, value]) => {
|
| 192 |
+
response.headers.set(key, value)
|
| 193 |
+
})
|
| 194 |
+
|
| 195 |
+
return response
|
| 196 |
+
} catch (error: any) {
|
| 197 |
+
// ========== ERROR HANDLING ==========
|
| 198 |
+
// Log detailed error server-side but return generic message to client
|
| 199 |
+
// OWASP: Prevent information disclosure
|
| 200 |
+
console.error('Prediction error:', error)
|
| 201 |
+
return createSecureResponse(
|
| 202 |
+
{ error: 'Prediction failed. Please try again later.' },
|
| 203 |
+
500
|
| 204 |
+
)
|
| 205 |
+
}
|
| 206 |
+
}
|
app/globals.css
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
/* Hide Google Maps "For development purposes only" watermark */
|
| 6 |
+
.gm-style-cc,
|
| 7 |
+
.gm-style-cc div,
|
| 8 |
+
.gm-style-cc a,
|
| 9 |
+
a[href^="https://developers.google.com/maps"],
|
| 10 |
+
.gm-bundled-control .gm-style-cc,
|
| 11 |
+
div[title*="For development purposes only"] {
|
| 12 |
+
display: none !important;
|
| 13 |
+
visibility: hidden !important;
|
| 14 |
+
opacity: 0 !important;
|
| 15 |
+
height: 0 !important;
|
| 16 |
+
width: 0 !important;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/* Hide Google Maps default POI marker images only (scope to .gm-style — unscoped img rules break non-map UI) */
|
| 20 |
+
.gm-style img[src*="poi"],
|
| 21 |
+
.gm-style img[src*="place"],
|
| 22 |
+
.gm-style img[src*="marker"][src*="default"] {
|
| 23 |
+
display: none !important;
|
| 24 |
+
visibility: hidden !important;
|
| 25 |
+
opacity: 0 !important;
|
| 26 |
+
height: 0 !important;
|
| 27 |
+
width: 0 !important;
|
| 28 |
+
pointer-events: none !important;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/* Leaflet map styles */
|
| 32 |
+
.leaflet-container {
|
| 33 |
+
height: 100%;
|
| 34 |
+
width: 100%;
|
| 35 |
+
z-index: 0;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.leaflet-popup-content-wrapper {
|
| 39 |
+
border-radius: 8px;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.custom-marker {
|
| 43 |
+
background: transparent !important;
|
| 44 |
+
border: none !important;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
:root {
|
| 48 |
+
--foreground-rgb: 0, 0, 0;
|
| 49 |
+
--background-start-rgb: 214, 219, 220;
|
| 50 |
+
--background-end-rgb: 255, 255, 255;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
* {
|
| 54 |
+
@apply antialiased;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
body {
|
| 58 |
+
color: rgb(var(--foreground-rgb));
|
| 59 |
+
background:
|
| 60 |
+
radial-gradient(1200px 600px at 20% 0%, rgba(112, 152, 112, 0.18), transparent 55%),
|
| 61 |
+
radial-gradient(900px 500px at 90% 10%, rgba(120, 160, 120, 0.16), transparent 60%),
|
| 62 |
+
linear-gradient(180deg, #ffffff 0%, #f8fafc 55%, #f1f5f9 100%);
|
| 63 |
+
min-height: 100vh;
|
| 64 |
+
min-height: 100dvh;
|
| 65 |
+
overflow-x: hidden;
|
| 66 |
+
padding-left: env(safe-area-inset-left);
|
| 67 |
+
padding-right: env(safe-area-inset-right);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/* Ensure images display correctly */
|
| 71 |
+
img {
|
| 72 |
+
display: block !important;
|
| 73 |
+
max-width: 100%;
|
| 74 |
+
height: auto;
|
| 75 |
+
object-fit: contain;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/* Prevent images from showing as broken icons */
|
| 79 |
+
img[src=""],
|
| 80 |
+
img:not([src]) {
|
| 81 |
+
display: none !important;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/* Ensure image containers don't collapse */
|
| 85 |
+
.image-container,
|
| 86 |
+
[class*="image"] {
|
| 87 |
+
min-height: 100px;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
@layer utilities {
|
| 91 |
+
.text-balance {
|
| 92 |
+
text-wrap: balance;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.glass {
|
| 96 |
+
background: rgba(255, 255, 255, 0.95);
|
| 97 |
+
backdrop-filter: blur(10px);
|
| 98 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.gradient-text {
|
| 102 |
+
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary-600 to-blue-600;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.surface {
|
| 106 |
+
@apply bg-white border border-slate-200/80 shadow-sm;
|
| 107 |
+
}
|
| 108 |
+
}
|
app/layout.tsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata, Viewport } from 'next'
|
| 2 |
+
import './globals.css'
|
| 3 |
+
import { ThemeProvider } from '@/components/ui/theme-provider'
|
| 4 |
+
|
| 5 |
+
export const metadata: Metadata = {
|
| 6 |
+
title: 'CropIntel - AI-Powered Crop Disease Classification',
|
| 7 |
+
description: 'Upload crop leaf images to detect diseases using AI',
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export const viewport: Viewport = {
|
| 11 |
+
width: 'device-width',
|
| 12 |
+
initialScale: 1,
|
| 13 |
+
viewportFit: 'cover',
|
| 14 |
+
themeColor: '#f8fafc',
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export default function RootLayout({
|
| 18 |
+
children,
|
| 19 |
+
}: {
|
| 20 |
+
children: React.ReactNode
|
| 21 |
+
}) {
|
| 22 |
+
return (
|
| 23 |
+
<html lang="en" suppressHydrationWarning>
|
| 24 |
+
<body>
|
| 25 |
+
<ThemeProvider
|
| 26 |
+
attribute="class"
|
| 27 |
+
defaultTheme="light"
|
| 28 |
+
enableSystem
|
| 29 |
+
disableTransitionOnChange
|
| 30 |
+
>
|
| 31 |
+
{children}
|
| 32 |
+
</ThemeProvider>
|
| 33 |
+
</body>
|
| 34 |
+
</html>
|
| 35 |
+
)
|
| 36 |
+
}
|
app/outbreaks/page.tsx
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import OutbreakMap from '@/components/OutbreakMap'
|
| 2 |
+
|
| 3 |
+
export default function OutbreaksPage() {
|
| 4 |
+
return <OutbreakMap />
|
| 5 |
+
}
|
app/page.tsx
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useCallback } from 'react'
|
| 4 |
+
import dynamic from 'next/dynamic'
|
| 5 |
+
import Image from 'next/image'
|
| 6 |
+
import { Bell, MapPin, Sparkles, History as HistoryIcon, ArrowRight, Loader2, Camera, ArrowLeftRight } from 'lucide-react'
|
| 7 |
+
import ImageUpload from '@/components/ImageUpload'
|
| 8 |
+
import CropSelector from '@/components/CropSelector'
|
| 9 |
+
import StateSelector from '@/components/StateSelector'
|
| 10 |
+
import PredictionResults from '@/components/PredictionResults'
|
| 11 |
+
import DiseaseInfo from '@/components/DiseaseInfo'
|
| 12 |
+
import PredictionHistory from '@/components/PredictionHistory'
|
| 13 |
+
import ExportResults from '@/components/ExportResults'
|
| 14 |
+
import TipsAndGuidelines from '@/components/TipsAndGuidelines'
|
| 15 |
+
import Diagnosis from '@/components/Diagnosis'
|
| 16 |
+
import NotificationSystem from '@/components/NotificationSystem'
|
| 17 |
+
import FarmerRegistration from '@/components/FarmerRegistration'
|
| 18 |
+
import FarmerVerificationBadge from '@/components/FarmerVerificationBadge'
|
| 19 |
+
import HealthComparisonPanel from '@/components/HealthComparisonPanel'
|
| 20 |
+
import { savePredictionToHistory } from '@/components/PredictionHistory'
|
| 21 |
+
import { CROPS } from '@/lib/crops'
|
| 22 |
+
import type { OutbreakReport } from '@/lib/outbreakReport'
|
| 23 |
+
import { loadFarmerProfile, saveFarmerProfile, type StoredFarmerProfile } from '@/lib/farmerProfile'
|
| 24 |
+
import {
|
| 25 |
+
applyRegionalPrior,
|
| 26 |
+
getRelevantDiseasesForCropState,
|
| 27 |
+
type PredictionPayload,
|
| 28 |
+
} from '@/lib/stateDiseaseMap'
|
| 29 |
+
|
| 30 |
+
const USOutbreakMap = dynamic(() => import('@/components/USOutbreakMap'), {
|
| 31 |
+
ssr: false,
|
| 32 |
+
loading: () => (
|
| 33 |
+
<div className="flex min-h-[280px] w-full items-center justify-center rounded-xl border border-slate-200 bg-slate-50 sm:min-h-[420px]">
|
| 34 |
+
<Loader2 className="h-8 w-8 animate-spin text-primary-700" aria-label="Loading map" />
|
| 35 |
+
</div>
|
| 36 |
+
),
|
| 37 |
+
})
|
| 38 |
+
|
| 39 |
+
export default function Home() {
|
| 40 |
+
const [selectedImage, setSelectedImage] = useState<File | null>(null)
|
| 41 |
+
const [selectedCrop, setSelectedCrop] = useState<string>('corn')
|
| 42 |
+
const [selectedState, setSelectedState] = useState<string>('IA')
|
| 43 |
+
const [photoMode, setPhotoMode] = useState<'single' | 'compare'>('single')
|
| 44 |
+
const [farmerProfile, setFarmerProfile] = useState<StoredFarmerProfile | null>(null)
|
| 45 |
+
const [prediction, setPrediction] = useState<any>(null)
|
| 46 |
+
const [loading, setLoading] = useState(false)
|
| 47 |
+
const [error, setError] = useState<string | null>(null)
|
| 48 |
+
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
| 49 |
+
const [activeView, setActiveView] = useState<'diagnose' | 'history' | 'outbreaks'>('diagnose')
|
| 50 |
+
// Initialize with a sample outbreak in Russellville, Arkansas
|
| 51 |
+
const [outbreakReports, setOutbreakReports] = useState<OutbreakReport[]>([
|
| 52 |
+
{
|
| 53 |
+
id: 'russellville-outbreak-1',
|
| 54 |
+
lat: 35.2784,
|
| 55 |
+
lng: -93.1338,
|
| 56 |
+
crop: 'corn',
|
| 57 |
+
disease: 'Common Rust',
|
| 58 |
+
severity: 'high',
|
| 59 |
+
date: new Date().toISOString(),
|
| 60 |
+
description: 'Severe rust outbreak detected in corn fields. Multiple farms affected in the area.',
|
| 61 |
+
reporterVerified: false,
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
id: 'high-severity-130-miles',
|
| 65 |
+
lat: 33.6234, // Exactly 130 miles south of farmer-1 (35.5, -93.2)
|
| 66 |
+
lng: -93.2,
|
| 67 |
+
crop: 'corn',
|
| 68 |
+
disease: 'Southern Corn Leaf Blight',
|
| 69 |
+
severity: 'high',
|
| 70 |
+
date: new Date().toISOString(),
|
| 71 |
+
description: 'CRITICAL: Severe southern corn leaf blight outbreak detected. Immediate action required. Multiple farms at risk within 150-mile radius.',
|
| 72 |
+
reporterVerified: false,
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
id: 'california-outbreak-1',
|
| 76 |
+
lat: 36.7783,
|
| 77 |
+
lng: -119.4179,
|
| 78 |
+
crop: 'wheat',
|
| 79 |
+
disease: 'Leaf Rust',
|
| 80 |
+
severity: 'high',
|
| 81 |
+
date: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
| 82 |
+
description: 'Widespread leaf rust detected in wheat fields across Central Valley.',
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
id: 'texas-outbreak-1',
|
| 86 |
+
lat: 31.9686,
|
| 87 |
+
lng: -99.9018,
|
| 88 |
+
crop: 'corn',
|
| 89 |
+
disease: 'Gray Leaf Spot',
|
| 90 |
+
severity: 'medium',
|
| 91 |
+
date: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
| 92 |
+
description: 'Gray leaf spot spreading in corn crops. Farmers advised to monitor closely.',
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
id: 'iowa-outbreak-1',
|
| 96 |
+
lat: 41.8780,
|
| 97 |
+
lng: -93.0977,
|
| 98 |
+
crop: 'soybean',
|
| 99 |
+
disease: 'Powdery Mildew',
|
| 100 |
+
severity: 'medium',
|
| 101 |
+
date: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
| 102 |
+
description: 'Powdery mildew detected in soybean fields. Early treatment recommended.',
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
id: 'illinois-outbreak-1',
|
| 106 |
+
lat: 40.3495,
|
| 107 |
+
lng: -88.9861,
|
| 108 |
+
crop: 'corn',
|
| 109 |
+
disease: 'Common Rust',
|
| 110 |
+
severity: 'low',
|
| 111 |
+
date: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
|
| 112 |
+
description: 'Minor rust outbreak in isolated corn fields. Monitoring in progress.',
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
id: 'kansas-outbreak-1',
|
| 116 |
+
lat: 38.5729,
|
| 117 |
+
lng: -98.3833,
|
| 118 |
+
crop: 'wheat',
|
| 119 |
+
disease: 'Stripe Rust',
|
| 120 |
+
severity: 'high',
|
| 121 |
+
date: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(),
|
| 122 |
+
description: 'Severe stripe rust outbreak affecting wheat crops. Immediate action required.',
|
| 123 |
+
},
|
| 124 |
+
{
|
| 125 |
+
id: 'nebraska-outbreak-1',
|
| 126 |
+
lat: 41.4925,
|
| 127 |
+
lng: -99.9018,
|
| 128 |
+
crop: 'corn',
|
| 129 |
+
disease: 'Northern Corn Leaf Blight',
|
| 130 |
+
severity: 'medium',
|
| 131 |
+
date: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(),
|
| 132 |
+
description: 'Northern corn leaf blight detected. Fungicide application recommended.',
|
| 133 |
+
},
|
| 134 |
+
{
|
| 135 |
+
id: 'minnesota-outbreak-1',
|
| 136 |
+
lat: 46.7296,
|
| 137 |
+
lng: -94.6859,
|
| 138 |
+
crop: 'soybean',
|
| 139 |
+
disease: 'Bacterial Blight',
|
| 140 |
+
severity: 'low',
|
| 141 |
+
date: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
| 142 |
+
description: 'Bacterial blight found in soybean fields. Isolated cases reported.',
|
| 143 |
+
},
|
| 144 |
+
{
|
| 145 |
+
id: 'north-carolina-outbreak-1',
|
| 146 |
+
lat: 35.2271,
|
| 147 |
+
lng: -80.8431,
|
| 148 |
+
crop: 'corn',
|
| 149 |
+
disease: 'Southern Corn Leaf Blight',
|
| 150 |
+
severity: 'high',
|
| 151 |
+
date: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
| 152 |
+
description: 'Severe southern corn leaf blight outbreak. Multiple counties affected.',
|
| 153 |
+
},
|
| 154 |
+
{
|
| 155 |
+
id: 'missouri-outbreak-1',
|
| 156 |
+
lat: 38.5729,
|
| 157 |
+
lng: -92.1893,
|
| 158 |
+
crop: 'soybean',
|
| 159 |
+
disease: 'Sudden Death Syndrome',
|
| 160 |
+
severity: 'medium',
|
| 161 |
+
date: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(),
|
| 162 |
+
description: 'Sudden death syndrome detected in soybean crops. Root health monitoring advised.',
|
| 163 |
+
},
|
| 164 |
+
{
|
| 165 |
+
id: 'indiana-outbreak-1',
|
| 166 |
+
lat: 39.7684,
|
| 167 |
+
lng: -86.1581,
|
| 168 |
+
crop: 'corn',
|
| 169 |
+
disease: 'Common Rust',
|
| 170 |
+
severity: 'low',
|
| 171 |
+
date: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
|
| 172 |
+
description: 'Minor rust spots detected. Early stage monitoring.',
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
id: 'ohio-outbreak-1',
|
| 176 |
+
lat: 40.3888,
|
| 177 |
+
lng: -82.7649,
|
| 178 |
+
crop: 'corn',
|
| 179 |
+
disease: 'Gray Leaf Spot',
|
| 180 |
+
severity: 'medium',
|
| 181 |
+
date: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
| 182 |
+
description: 'Gray leaf spot spreading in corn fields. Weather conditions favorable for spread.',
|
| 183 |
+
},
|
| 184 |
+
])
|
| 185 |
+
const [farmerLocation, setFarmerLocation] = useState<{ lat: number; lng: number; crops: string[] } | null>(null)
|
| 186 |
+
|
| 187 |
+
useEffect(() => {
|
| 188 |
+
const p = loadFarmerProfile()
|
| 189 |
+
if (p) {
|
| 190 |
+
setFarmerProfile(p)
|
| 191 |
+
setFarmerLocation({ lat: p.lat, lng: p.lng, crops: p.crops })
|
| 192 |
+
}
|
| 193 |
+
}, [])
|
| 194 |
+
|
| 195 |
+
const applyRegionalFilter = useCallback(
|
| 196 |
+
(raw: PredictionPayload) => applyRegionalPrior(raw, selectedCrop, selectedState),
|
| 197 |
+
[selectedCrop, selectedState]
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
const regionNote =
|
| 201 |
+
getRelevantDiseasesForCropState(selectedCrop, selectedState) !== null
|
| 202 |
+
? `Regional adjustment: results are gently nudged toward diseases common for ${selectedCrop} in ${selectedState} (illustrative; capped so it never overrides the model). Other states or crops show the model's raw output.`
|
| 203 |
+
: undefined
|
| 204 |
+
|
| 205 |
+
const handlePredict = async () => {
|
| 206 |
+
if (!selectedImage) {
|
| 207 |
+
setError('Please select an image first')
|
| 208 |
+
return
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
setLoading(true)
|
| 212 |
+
setError(null)
|
| 213 |
+
setPrediction(null)
|
| 214 |
+
|
| 215 |
+
try {
|
| 216 |
+
const formData = new FormData()
|
| 217 |
+
formData.append('image', selectedImage)
|
| 218 |
+
formData.append('crop', selectedCrop)
|
| 219 |
+
|
| 220 |
+
const response = await fetch('/api/predict', {
|
| 221 |
+
method: 'POST',
|
| 222 |
+
body: formData,
|
| 223 |
+
})
|
| 224 |
+
|
| 225 |
+
if (!response.ok) {
|
| 226 |
+
const errorData = await response.json()
|
| 227 |
+
throw new Error(errorData.error || 'Prediction failed')
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
const data = await response.json()
|
| 231 |
+
const rawPayload: PredictionPayload = {
|
| 232 |
+
disease: data.disease,
|
| 233 |
+
confidence: data.confidence,
|
| 234 |
+
is_healthy: data.is_healthy,
|
| 235 |
+
meets_threshold: data.meets_threshold,
|
| 236 |
+
all_predictions: data.all_predictions,
|
| 237 |
+
}
|
| 238 |
+
const filtered = applyRegionalFilter(rawPayload)
|
| 239 |
+
const merged = { ...data, ...filtered }
|
| 240 |
+
setPrediction(merged)
|
| 241 |
+
|
| 242 |
+
// Save to history
|
| 243 |
+
if (imageUrl) {
|
| 244 |
+
const confidencePercent =
|
| 245 |
+
typeof merged.confidence === 'number' && merged.confidence <= 1
|
| 246 |
+
? merged.confidence * 100
|
| 247 |
+
: merged.confidence
|
| 248 |
+
savePredictionToHistory(selectedCrop, merged.disease, confidencePercent, imageUrl)
|
| 249 |
+
}
|
| 250 |
+
} catch (err: any) {
|
| 251 |
+
setError(err.message || 'An error occurred')
|
| 252 |
+
} finally {
|
| 253 |
+
setLoading(false)
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
const handleClear = () => {
|
| 258 |
+
setSelectedImage(null)
|
| 259 |
+
setPrediction(null)
|
| 260 |
+
setError(null)
|
| 261 |
+
setImageUrl(null)
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
const handleImageSelect = (file: File | null) => {
|
| 265 |
+
setSelectedImage(file)
|
| 266 |
+
if (file) {
|
| 267 |
+
const url = URL.createObjectURL(file)
|
| 268 |
+
setImageUrl(url)
|
| 269 |
+
} else {
|
| 270 |
+
setImageUrl(null)
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
const handleHistorySelect = (record: any) => {
|
| 275 |
+
// Load image from history
|
| 276 |
+
setImageUrl(record.imageUrl)
|
| 277 |
+
setSelectedCrop(record.crop)
|
| 278 |
+
// Note: We can't reload the File object from URL, but we can show the prediction
|
| 279 |
+
// In a real app, you might want to store more data in history
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
const handleOutbreakReport = (report: OutbreakReport) => {
|
| 283 |
+
setOutbreakReports([...outbreakReports, report])
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
const handleFarmerRegister = (location: {
|
| 287 |
+
lat: number
|
| 288 |
+
lng: number
|
| 289 |
+
crops: string[]
|
| 290 |
+
name: string
|
| 291 |
+
email?: string
|
| 292 |
+
usdaFarmCode?: string
|
| 293 |
+
verifiedFarmer: boolean
|
| 294 |
+
}) => {
|
| 295 |
+
const profile: StoredFarmerProfile = {
|
| 296 |
+
name: location.name,
|
| 297 |
+
email: location.email,
|
| 298 |
+
lat: location.lat,
|
| 299 |
+
lng: location.lng,
|
| 300 |
+
crops: location.crops,
|
| 301 |
+
usdaFarmCode: location.usdaFarmCode,
|
| 302 |
+
verifiedFarmer: location.verifiedFarmer,
|
| 303 |
+
}
|
| 304 |
+
saveFarmerProfile(profile)
|
| 305 |
+
setFarmerProfile(profile)
|
| 306 |
+
setFarmerLocation({
|
| 307 |
+
lat: location.lat,
|
| 308 |
+
lng: location.lng,
|
| 309 |
+
crops: location.crops,
|
| 310 |
+
})
|
| 311 |
+
alert(
|
| 312 |
+
`Farm "${location.name}" registered! You'll now receive alerts for outbreaks within 250 miles.${
|
| 313 |
+
location.verifiedFarmer ? ' You are marked as a Verified farmer.' : ''
|
| 314 |
+
}`
|
| 315 |
+
)
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
return (
|
| 319 |
+
<main className="min-h-screen min-h-[100dvh] px-3 sm:px-4 py-4 sm:py-6 pb-[max(1.25rem,env(safe-area-inset-bottom))]">
|
| 320 |
+
<div className="mx-auto max-w-7xl">
|
| 321 |
+
{/* Top bar */}
|
| 322 |
+
<header className="sticky top-0 z-40 -mx-3 sm:-mx-4 px-3 sm:px-4 pt-[max(0.5rem,env(safe-area-inset-top))] pb-3 border-b border-slate-200/60 bg-white/95">
|
| 323 |
+
<div className="mx-auto max-w-7xl flex items-center justify-between gap-2 sm:gap-3">
|
| 324 |
+
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
| 325 |
+
<div className="w-10 h-10 shrink-0 rounded-2xl bg-gradient-to-br from-primary-600 to-primary-700 text-white flex items-center justify-center shadow-sm overflow-hidden">
|
| 326 |
+
<Image
|
| 327 |
+
src="/brand/wheat-mark-transparent.png"
|
| 328 |
+
alt="CropIntel"
|
| 329 |
+
width={22}
|
| 330 |
+
height={44}
|
| 331 |
+
className="opacity-95 object-contain drop-shadow-[0_1px_0_rgba(0,0,0,0.08)]"
|
| 332 |
+
priority
|
| 333 |
+
/>
|
| 334 |
+
</div>
|
| 335 |
+
<div className="leading-tight min-w-0">
|
| 336 |
+
<div className="text-sm font-semibold text-slate-900 truncate">CropIntel</div>
|
| 337 |
+
<div className="text-[11px] sm:text-xs text-slate-500 leading-snug">Crop health insights</div>
|
| 338 |
+
</div>
|
| 339 |
+
</div>
|
| 340 |
+
|
| 341 |
+
<nav className="hidden md:flex items-center gap-2">
|
| 342 |
+
<a
|
| 343 |
+
href="/"
|
| 344 |
+
className="px-3 py-2 rounded-xl text-sm font-semibold text-slate-700 hover:text-slate-900 hover:bg-slate-100 transition-colors"
|
| 345 |
+
>
|
| 346 |
+
Diagnose
|
| 347 |
+
</a>
|
| 348 |
+
</nav>
|
| 349 |
+
|
| 350 |
+
<div className="flex items-center gap-1.5 sm:gap-2 flex-nowrap shrink-0 justify-end">
|
| 351 |
+
{farmerProfile && (
|
| 352 |
+
<FarmerVerificationBadge verified={farmerProfile.verifiedFarmer} compact />
|
| 353 |
+
)}
|
| 354 |
+
<div className="hidden sm:block">
|
| 355 |
+
<FarmerRegistration onRegister={handleFarmerRegister} crops={Object.keys(CROPS)} />
|
| 356 |
+
</div>
|
| 357 |
+
<div className="relative z-50">
|
| 358 |
+
<NotificationSystem outbreaks={outbreakReports} currentFarmerLocation={farmerLocation || undefined} />
|
| 359 |
+
</div>
|
| 360 |
+
</div>
|
| 361 |
+
</div>
|
| 362 |
+
</header>
|
| 363 |
+
|
| 364 |
+
{/* Hero */}
|
| 365 |
+
<section className="mt-5 sm:mt-8 mb-5 sm:mb-6">
|
| 366 |
+
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
|
| 367 |
+
<div className="max-w-2xl">
|
| 368 |
+
<h1 className="text-2xl sm:text-3xl md:text-4xl font-semibold text-slate-900 tracking-tight text-balance">
|
| 369 |
+
Diagnose crop issues from a photo
|
| 370 |
+
</h1>
|
| 371 |
+
<p className="mt-2 text-sm sm:text-base text-slate-600">
|
| 372 |
+
Upload a leaf image, pick the crop, and get a ranked set of labels with confidence.
|
| 373 |
+
</p>
|
| 374 |
+
</div>
|
| 375 |
+
<div className="flex items-center gap-2">
|
| 376 |
+
<div className="px-3 py-2 rounded-xl border border-slate-200 bg-white/80 text-sm text-slate-700 flex items-center gap-2">
|
| 377 |
+
<Bell className="w-4 h-4 text-primary-700" />
|
| 378 |
+
Outbreak alerts
|
| 379 |
+
</div>
|
| 380 |
+
</div>
|
| 381 |
+
</div>
|
| 382 |
+
</section>
|
| 383 |
+
|
| 384 |
+
{/* Views */}
|
| 385 |
+
<div className="grid grid-cols-3 gap-2 mb-5 sm:mb-6 sm:flex sm:flex-wrap sm:items-center">
|
| 386 |
+
{(
|
| 387 |
+
[
|
| 388 |
+
{ id: 'diagnose', label: 'Diagnose', icon: Sparkles },
|
| 389 |
+
{ id: 'history', label: 'History', icon: HistoryIcon },
|
| 390 |
+
{ id: 'outbreaks', label: 'Outbreaks', icon: MapPin },
|
| 391 |
+
] as const
|
| 392 |
+
).map(({ id, label, icon: Icon }) => (
|
| 393 |
+
<button
|
| 394 |
+
key={id}
|
| 395 |
+
type="button"
|
| 396 |
+
onClick={() => setActiveView(id)}
|
| 397 |
+
className={`touch-manipulation min-h-[44px] px-2 sm:px-4 py-2 rounded-xl border text-xs sm:text-sm font-semibold transition-all flex items-center justify-center gap-1.5 sm:gap-2 ${
|
| 398 |
+
activeView === id
|
| 399 |
+
? 'bg-primary-700 text-white border-primary-700'
|
| 400 |
+
: 'bg-white/70 text-slate-700 border-slate-200 hover:bg-white hover:border-primary-300'
|
| 401 |
+
}`}
|
| 402 |
+
>
|
| 403 |
+
<Icon className="w-4 h-4 shrink-0" />
|
| 404 |
+
<span className="truncate">{label}</span>
|
| 405 |
+
</button>
|
| 406 |
+
))}
|
| 407 |
+
</div>
|
| 408 |
+
|
| 409 |
+
{activeView === 'diagnose' && (
|
| 410 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 411 |
+
<div className="lg:col-span-2 space-y-6">
|
| 412 |
+
<section className="surface rounded-2xl p-4 sm:p-6">
|
| 413 |
+
<div className="flex items-start justify-between gap-4 flex-wrap">
|
| 414 |
+
<div>
|
| 415 |
+
<h2 className="text-base font-semibold text-slate-900">Photo analysis</h2>
|
| 416 |
+
<p className="text-sm text-slate-600 mt-1">Best results with a sharp, well-lit close-up.</p>
|
| 417 |
+
</div>
|
| 418 |
+
<div className="text-xs text-slate-500">
|
| 419 |
+
Step <span className="font-semibold text-slate-700">1</span> of 3
|
| 420 |
+
</div>
|
| 421 |
+
</div>
|
| 422 |
+
|
| 423 |
+
<div className="mt-5 flex flex-wrap gap-2">
|
| 424 |
+
<button
|
| 425 |
+
type="button"
|
| 426 |
+
onClick={() => setPhotoMode('single')}
|
| 427 |
+
className={`touch-manipulation min-h-[44px] px-4 py-2 rounded-xl border text-sm font-semibold flex items-center gap-2 transition-all ${
|
| 428 |
+
photoMode === 'single'
|
| 429 |
+
? 'bg-primary-700 text-white border-primary-700'
|
| 430 |
+
: 'bg-white text-slate-700 border-slate-200 hover:border-primary-300'
|
| 431 |
+
}`}
|
| 432 |
+
>
|
| 433 |
+
<Camera className="w-4 h-4" />
|
| 434 |
+
Single photo
|
| 435 |
+
</button>
|
| 436 |
+
<button
|
| 437 |
+
type="button"
|
| 438 |
+
onClick={() => setPhotoMode('compare')}
|
| 439 |
+
className={`touch-manipulation min-h-[44px] px-4 py-2 rounded-xl border text-sm font-semibold flex items-center gap-2 transition-all ${
|
| 440 |
+
photoMode === 'compare'
|
| 441 |
+
? 'bg-primary-700 text-white border-primary-700'
|
| 442 |
+
: 'bg-white text-slate-700 border-slate-200 hover:border-primary-300'
|
| 443 |
+
}`}
|
| 444 |
+
>
|
| 445 |
+
<ArrowLeftRight className="w-4 h-4" />
|
| 446 |
+
Past vs current
|
| 447 |
+
</button>
|
| 448 |
+
</div>
|
| 449 |
+
|
| 450 |
+
<div className="mt-5 space-y-5">
|
| 451 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 452 |
+
<CropSelector crops={Object.keys(CROPS)} selectedCrop={selectedCrop} onCropChange={setSelectedCrop} />
|
| 453 |
+
<StateSelector selectedState={selectedState} onStateChange={setSelectedState} />
|
| 454 |
+
</div>
|
| 455 |
+
|
| 456 |
+
{photoMode === 'single' && (
|
| 457 |
+
<>
|
| 458 |
+
<ImageUpload selectedImage={selectedImage} onImageSelect={handleImageSelect} onClear={handleClear} />
|
| 459 |
+
<div className="flex items-end">
|
| 460 |
+
<button
|
| 461 |
+
type="button"
|
| 462 |
+
onClick={handlePredict}
|
| 463 |
+
disabled={!selectedImage || loading}
|
| 464 |
+
className="touch-manipulation min-h-[48px] w-full md:max-w-md inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl bg-primary-700 text-white font-semibold shadow-sm hover:bg-primary-800 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors"
|
| 465 |
+
>
|
| 466 |
+
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowRight className="w-4 h-4" />}
|
| 467 |
+
{loading ? 'Analyzing…' : 'Run analysis'}
|
| 468 |
+
</button>
|
| 469 |
+
</div>
|
| 470 |
+
</>
|
| 471 |
+
)}
|
| 472 |
+
|
| 473 |
+
{photoMode === 'compare' && (
|
| 474 |
+
<HealthComparisonPanel crop={selectedCrop} applyRegionalFilter={applyRegionalFilter} />
|
| 475 |
+
)}
|
| 476 |
+
|
| 477 |
+
{error && (
|
| 478 |
+
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-rose-900">
|
| 479 |
+
<div className="text-sm font-semibold">Something went wrong</div>
|
| 480 |
+
<div className="text-sm mt-1">{error}</div>
|
| 481 |
+
</div>
|
| 482 |
+
)}
|
| 483 |
+
|
| 484 |
+
{photoMode === 'single' && prediction && (
|
| 485 |
+
<>
|
| 486 |
+
<PredictionResults prediction={prediction} regionNote={regionNote} />
|
| 487 |
+
<Diagnosis
|
| 488 |
+
disease={prediction.disease}
|
| 489 |
+
crop={selectedCrop}
|
| 490 |
+
confidence={
|
| 491 |
+
typeof prediction.confidence === 'number' && prediction.confidence <= 1
|
| 492 |
+
? prediction.confidence * 100
|
| 493 |
+
: prediction.confidence
|
| 494 |
+
}
|
| 495 |
+
isHealthy={prediction.is_healthy}
|
| 496 |
+
/>
|
| 497 |
+
<DiseaseInfo diseaseName={prediction.disease} crop={selectedCrop} />
|
| 498 |
+
<ExportResults prediction={prediction} crop={selectedCrop} imageUrl={imageUrl} />
|
| 499 |
+
</>
|
| 500 |
+
)}
|
| 501 |
+
</div>
|
| 502 |
+
</section>
|
| 503 |
+
</div>
|
| 504 |
+
|
| 505 |
+
<aside className="lg:col-span-1 space-y-6">
|
| 506 |
+
<TipsAndGuidelines />
|
| 507 |
+
|
| 508 |
+
<div className="surface rounded-2xl p-4 sm:p-6">
|
| 509 |
+
<h3 className="text-base font-semibold text-slate-900">Alerts</h3>
|
| 510 |
+
<p className="text-sm text-slate-600 mt-1">
|
| 511 |
+
Register a farm location to receive outbreak notifications within 250 miles.
|
| 512 |
+
</p>
|
| 513 |
+
<div className="mt-4 sm:hidden space-y-3">
|
| 514 |
+
{farmerProfile && (
|
| 515 |
+
<FarmerVerificationBadge verified={farmerProfile.verifiedFarmer} />
|
| 516 |
+
)}
|
| 517 |
+
<FarmerRegistration onRegister={handleFarmerRegister} crops={Object.keys(CROPS)} />
|
| 518 |
+
</div>
|
| 519 |
+
</div>
|
| 520 |
+
</aside>
|
| 521 |
+
</div>
|
| 522 |
+
)}
|
| 523 |
+
|
| 524 |
+
{activeView === 'history' && (
|
| 525 |
+
<section className="surface rounded-2xl p-4 sm:p-6">
|
| 526 |
+
<PredictionHistory onSelectHistory={handleHistorySelect} />
|
| 527 |
+
</section>
|
| 528 |
+
)}
|
| 529 |
+
|
| 530 |
+
{activeView === 'outbreaks' && (
|
| 531 |
+
<section className="rounded-2xl border border-slate-200/80 bg-white p-4 sm:p-6 shadow-sm">
|
| 532 |
+
<div className="mb-4">
|
| 533 |
+
<h2 className="text-base font-semibold text-slate-900">Outbreak map</h2>
|
| 534 |
+
<p className="text-sm text-slate-600 mt-1">
|
| 535 |
+
Tap or click to report a potential outbreak and help track disease spread.
|
| 536 |
+
</p>
|
| 537 |
+
</div>
|
| 538 |
+
<div className="bg-white rounded-xl p-2 sm:p-4 border border-slate-200 -mx-1 sm:mx-0">
|
| 539 |
+
<USOutbreakMap
|
| 540 |
+
reports={outbreakReports}
|
| 541 |
+
onReportSubmit={handleOutbreakReport}
|
| 542 |
+
reporterVerified={farmerProfile?.verifiedFarmer ?? false}
|
| 543 |
+
/>
|
| 544 |
+
</div>
|
| 545 |
+
</section>
|
| 546 |
+
)}
|
| 547 |
+
|
| 548 |
+
<footer className="text-center mt-10 mb-6 text-slate-500">
|
| 549 |
+
<p className="text-xs">Models: EfficientNet / TensorFlow Lite</p>
|
| 550 |
+
</footer>
|
| 551 |
+
</div>
|
| 552 |
+
</main>
|
| 553 |
+
)
|
| 554 |
+
}
|
combined_training_logs.csv
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
crop,epoch,phase,accuracy,val_accuracy,loss,val_loss,learning_rate
|
| 2 |
+
corn,0,Phase 1,0.25993428306022465,0.32931297537843107,2.0369233289997704,1.4760412881077356,9.999999747378752e-05
|
| 3 |
+
corn,1,Phase 1,0.2785918925391422,0.3290567562794731,1.9325749075275322,1.4272191047162242,9.999999747378752e-05
|
| 4 |
+
corn,2,Phase 1,0.3256681278871456,0.36849512867216616,1.8422305728166553,1.317687358398529,9.999999747378752e-05
|
| 5 |
+
corn,3,Phase 1,0.37453213281585807,0.3722218831433424,1.7569642956246365,1.2496958294442122,9.999999747378752e-05
|
| 6 |
+
corn,4,Phase 1,0.3707456467557967,0.42340147466631173,1.6220998743537631,1.2866345053173707,9.999999747378752e-05
|
| 7 |
+
corn,5,Phase 1,0.4021031536738457,0.47007986324021656,1.5840402569204328,1.2503222691756168,9.999999747378752e-05
|
| 8 |
+
corn,6,Phase 1,0.46972732768554293,0.478413822186777,1.5210070222602126,1.1154118152174743,9.999999747378752e-05
|
| 9 |
+
corn,7,Phase 1,0.4848489445210191,0.5425199363913403,1.5209015655871811,1.1056910197405077,9.999999747378752e-05
|
| 10 |
+
corn,8,Phase 1,0.4914679407818278,0.5565654845152547,1.4092328622226922,1.0100982296477032,9.999999747378752e-05
|
| 11 |
+
corn,9,Phase 1,0.543065807934812,0.596316665606969,1.2279064334441658,0.8962624942144805,9.999999747378752e-05
|
| 12 |
+
corn,10,Phase 1,0.5543034317694093,0.6236886906474505,1.2562691331500764,0.8830903157695725,9.999999747378752e-05
|
| 13 |
+
corn,11,Phase 1,0.5856143691168193,0.706340668871421,1.1448173141275542,0.8784244173668655,9.999999747378752e-05
|
| 14 |
+
corn,12,Phase 1,0.6311253881821108,0.7025974429756874,1.0542318216011057,0.7362333406374846,9.999999747378752e-05
|
| 15 |
+
corn,13,Phase 1,0.6211253881821108,0.715285451180532,1.0426682295264806,0.7527588789330977,9.999999747378752e-05
|
| 16 |
+
corn,14,Phase 1,0.6545021432256612,0.7864628502909176,0.9876408846772684,0.4800414944873253,9.999999747378752e-05
|
| 17 |
+
corn,15,Phase 1,0.7091119278536683,0.7791673613386373,0.9066614079763144,0.5300414944873253,9.999999747378752e-05
|
| 18 |
+
corn,16,Phase 1,0.7314582345943652,0.8413337885273565,0.7421430193274062,0.4883852102025515,9.999999747378752e-05
|
| 19 |
+
corn,17,Phase 1,0.789356982215525,0.8315353964388699,0.6926497701640111,0.4055845428167646,9.999999747378752e-05
|
| 20 |
+
corn,18,Phase 1,0.7962687326157611,0.8777373602277004,0.6486800539947837,0.36162500271624837,9.999999747378752e-05
|
| 21 |
+
corn,19,Phase 1,0.8175403186620454,0.9418105882122555,0.6049006322487571,0.19416057170884385,9.999999747378752e-05
|
| 22 |
+
corn,20,Phase 2,0.8457863926887512,0.937873363494873,0.5561233758926392,0.2935390174388885,9.999999747378752e-05
|
| 23 |
+
corn,21,Phase 2,0.903787076473236,0.9462365508079528,0.388371080160141,0.262035459280014,9.999999747378752e-05
|
| 24 |
+
corn,22,Phase 2,0.9225520491600036,0.9462365508079528,0.3230624198913574,0.254949539899826,9.999999747378752e-05
|
| 25 |
+
corn,23,Phase 2,0.9303991794586182,0.9498208165168762,0.2902830243110657,0.2342860549688339,9.999999747378752e-05
|
| 26 |
+
corn,24,Phase 2,0.937563955783844,0.9569892287254332,0.2600772082805633,0.2179587632417678,9.999999747378752e-05
|
| 27 |
+
corn,25,Phase 2,0.9474582076072692,0.9569892287254332,0.2290883213281631,0.2086362540721893,9.999999747378752e-05
|
| 28 |
+
corn,26,Phase 2,0.955646514892578,0.958184003829956,0.2177858054637909,0.2032181024551391,9.999999747378752e-05
|
| 29 |
+
corn,27,Phase 2,0.9539406299591064,0.9569892287254332,0.2127372920513153,0.1997936218976974,9.999999747378752e-05
|
| 30 |
+
corn,28,Phase 2,0.960764229297638,0.9557945132255554,0.1953231990337371,0.2101073563098907,9.999999747378752e-05
|
| 31 |
+
corn,29,Phase 2,0.9638348817825316,0.9641577005386353,0.1763510555028915,0.1987897753715515,9.999999747378752e-05
|
| 32 |
+
corn,30,Phase 2,0.9679290056228638,0.959378719329834,0.1671520769596099,0.2080786377191543,9.999999747378752e-05
|
| 33 |
+
corn,31,Phase 2,0.9672466516494752,0.9629629850387572,0.1688824594020843,0.1954573094844818,9.999999747378752e-05
|
| 34 |
+
corn,32,Phase 2,0.9723643660545348,0.966547191143036,0.1588789671659469,0.1971011310815811,9.999999747378752e-05
|
| 35 |
+
corn,33,Phase 2,0.973387897014618,0.965352475643158,0.1644280403852462,0.1916911602020263,9.999999747378752e-05
|
| 36 |
+
corn,34,Phase 2,0.9706584811210632,0.959378719329834,0.1582569330930709,0.2046691477298736,9.999999747378752e-05
|
| 37 |
+
corn,35,Phase 2,0.976799726486206,0.9605734944343568,0.1445048004388809,0.1973313093185424,9.999999747378752e-05
|
| 38 |
+
corn,36,Phase 2,0.9761173725128174,0.966547191143036,0.1439969688653946,0.1956245303153991,9.999999747378752e-05
|
| 39 |
+
corn,37,Phase 2,0.9805527329444884,0.9629629850387572,0.1321845948696136,0.2232125252485275,9.999999747378752e-05
|
| 40 |
+
corn,38,Phase 2,0.9832821488380432,0.9629629850387572,0.125231996178627,0.226840078830719,9.999999747378752e-05
|
| 41 |
+
corn,39,Phase 2,0.9808939099311828,0.959378719329834,0.1315742135047912,0.217746764421463,4.999999873689376e-05
|
| 42 |
+
rice,0,Full Training,0.6245828866958618,0.7251461744308472,1.1133992671966553,0.8908655643463135,9.999999747378752e-05
|
| 43 |
+
rice,1,Full Training,0.753615140914917,0.8167641162872314,0.77748042345047,0.6302634477615356,9.999999747378752e-05
|
| 44 |
+
rice,2,Full Training,0.7975528240203857,0.8538011908531189,0.6093147993087769,0.4765673279762268,9.999999747378752e-05
|
| 45 |
+
rice,3,Full Training,0.8387096524238586,0.9064327478408812,0.5588080883026123,0.3105555772781372,9.999999747378752e-05
|
| 46 |
+
rice,4,Full Training,0.8520578145980835,0.92592591047287,0.459932804107666,0.2514636218547821,9.999999747378752e-05
|
| 47 |
+
rice,5,Full Training,0.8876529335975647,0.9512670636177064,0.3976957499980926,0.2131340056657791,9.999999747378752e-05
|
| 48 |
+
rice,6,Full Training,0.8943270444869995,0.9512670636177064,0.3720048069953918,0.1891122162342071,9.999999747378752e-05
|
| 49 |
+
rice,7,Full Training,0.9048943519592284,0.9688109159469604,0.3332550823688507,0.1626372188329696,9.999999747378752e-05
|
| 50 |
+
rice,8,Full Training,0.9104560613632202,0.9727095365524292,0.3271945714950561,0.1407057493925094,9.999999747378752e-05
|
| 51 |
+
rice,9,Full Training,0.9265850782394408,0.9727095365524292,0.3011743426322937,0.1404629349708557,9.999999747378752e-05
|
| 52 |
+
rice,10,Full Training,0.9249165654182434,0.9785575270652772,0.2785957753658294,0.1359473615884781,9.999999747378752e-05
|
| 53 |
+
rice,11,Full Training,0.931034505367279,0.9824561476707458,0.2625192403793335,0.1228999197483062,9.999999747378752e-05
|
| 54 |
+
rice,12,Full Training,0.9427141547203064,0.988304078578949,0.2276329547166824,0.1142292022705078,9.999999747378752e-05
|
| 55 |
+
rice,13,Full Training,0.9421579241752625,0.9824561476707458,0.2399077266454696,0.1122083067893982,9.999999747378752e-05
|
| 56 |
+
rice,14,Full Training,0.9566184878349304,0.9863547682762146,0.1969229727983474,0.1022859513759613,9.999999747378752e-05
|
| 57 |
+
rice,15,Full Training,0.9382647275924684,0.988304078578949,0.2329556792974472,0.1052324697375297,9.999999747378752e-05
|
| 58 |
+
rice,16,Full Training,0.9605116844177246,0.988304078578949,0.1990869343280792,0.1037070974707603,9.999999747378752e-05
|
| 59 |
+
rice,17,Full Training,0.9521690607070924,0.9941520690917968,0.2017015367746353,0.0948290079832077,9.999999747378752e-05
|
| 60 |
+
rice,18,Full Training,0.9610678553581238,0.9805068373680116,0.2004568725824356,0.1194441244006156,9.999999747378752e-05
|
| 61 |
+
rice,19,Full Training,0.969410479068756,0.9922027587890624,0.1614497154951095,0.0929179117083549,9.999999747378752e-05
|
| 62 |
+
soybean,0,Full Training,0.555831253528595,0.800000011920929,1.2680233716964722,0.833808958530426,9.999999747378752e-05
|
| 63 |
+
soybean,1,Full Training,0.774193525314331,0.8608695864677429,0.7881004810333252,0.6341260075569153,9.999999747378752e-05
|
| 64 |
+
soybean,2,Full Training,0.8287841081619263,0.895652174949646,0.4845375418663025,0.4956668317317962,9.999999747378752e-05
|
| 65 |
+
soybean,3,Full Training,0.8610422015190125,0.8782608509063721,0.4177505373954773,0.4525479972362518,9.999999747378752e-05
|
| 66 |
+
soybean,4,Full Training,0.8858560919761658,0.8782608509063721,0.3343152105808258,0.4741407930850982,9.999999747378752e-05
|
| 67 |
+
soybean,5,Full Training,0.9007444381713868,0.904347836971283,0.3207309544086456,0.3701974749565124,9.999999747378752e-05
|
| 68 |
+
soybean,6,Full Training,0.9205955266952516,0.939130425453186,0.2717101573944092,0.3247621357440948,9.999999747378752e-05
|
| 69 |
+
soybean,7,Full Training,0.9305210709571838,0.947826087474823,0.2848432958126068,0.2357503920793533,9.999999747378752e-05
|
| 70 |
+
soybean,8,Full Training,0.9478908181190492,0.95652174949646,0.2768489122390747,0.241691917181015,9.999999747378752e-05
|
| 71 |
+
soybean,9,Full Training,0.9503722190856934,0.95652174949646,0.2016389966011047,0.236976757645607,9.999999747378752e-05
|
| 72 |
+
soybean,10,Full Training,0.9578163623809814,0.95652174949646,0.1939270496368408,0.248374804854393,9.999999747378752e-05
|
| 73 |
+
soybean,11,Full Training,0.9677419066429138,0.9652174115180968,0.1636942774057388,0.278547078371048,9.999999747378752e-05
|
| 74 |
+
soybean,12,Full Training,0.9652605652809144,0.9652174115180968,0.1644345670938491,0.2483182996511459,9.999999747378752e-05
|
| 75 |
+
soybean,13,Full Training,0.9652605652809144,0.9652174115180968,0.1477585434913635,0.2468210905790329,4.999999873689376e-05
|
| 76 |
+
soybean,14,Full Training,0.9826302528381348,0.9652174115180968,0.1358078271150589,0.2343208342790603,4.999999873689376e-05
|
| 77 |
+
soybean,15,Full Training,0.9801489114761353,0.9652174115180968,0.1375887989997863,0.2198337763547897,4.999999873689376e-05
|
| 78 |
+
soybean,16,Full Training,0.9677419066429138,0.9652174115180968,0.1382406949996948,0.2439783215522766,4.999999873689376e-05
|
| 79 |
+
soybean,17,Full Training,0.9826302528381348,0.9652174115180968,0.1264931112527847,0.2558887302875519,4.999999873689376e-05
|
| 80 |
+
soybean,18,Full Training,0.9826302528381348,0.9652174115180968,0.1234887540340423,0.2484913766384124,4.999999873689376e-05
|
| 81 |
+
soybean,19,Full Training,0.9727047085762024,0.9652174115180968,0.1350847035646438,0.2422107458114624,4.999999873689376e-05
|
compare.sh
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/zsh
|
| 2 |
+
# Launch the local drag-and-drop model-compare tool.
|
| 3 |
+
# ./compare.sh
|
| 4 |
+
# Then open http://localhost:8050
|
| 5 |
+
cd "$(dirname "$0")"
|
| 6 |
+
PY=.conda-py311/bin/python
|
| 7 |
+
# Flask is required (one-time): $PY -m pip install flask
|
| 8 |
+
exec "$PY" -m ml.serve.compare_app
|
components/CropSelector.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { Leaf } from 'lucide-react'
|
| 4 |
+
|
| 5 |
+
interface CropSelectorProps {
|
| 6 |
+
crops: string[]
|
| 7 |
+
selectedCrop: string
|
| 8 |
+
onCropChange: (crop: string) => void
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export default function CropSelector({
|
| 12 |
+
crops,
|
| 13 |
+
selectedCrop,
|
| 14 |
+
onCropChange,
|
| 15 |
+
}: CropSelectorProps) {
|
| 16 |
+
return (
|
| 17 |
+
<div>
|
| 18 |
+
<label
|
| 19 |
+
htmlFor="crop-select"
|
| 20 |
+
className="block text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2"
|
| 21 |
+
>
|
| 22 |
+
<Leaf className="w-4 h-4 text-primary-700" />
|
| 23 |
+
Crop
|
| 24 |
+
</label>
|
| 25 |
+
<select
|
| 26 |
+
id="crop-select"
|
| 27 |
+
value={selectedCrop}
|
| 28 |
+
onChange={(e) => onCropChange(e.target.value)}
|
| 29 |
+
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-4 focus:ring-primary-200/60 focus:border-primary-400 text-base font-semibold bg-white shadow-sm hover:shadow-md transition-all duration-200 cursor-pointer"
|
| 30 |
+
>
|
| 31 |
+
{crops.map((crop) => (
|
| 32 |
+
<option key={crop} value={crop}>
|
| 33 |
+
{crop.charAt(0).toUpperCase() + crop.slice(1)}
|
| 34 |
+
</option>
|
| 35 |
+
))}
|
| 36 |
+
</select>
|
| 37 |
+
</div>
|
| 38 |
+
)
|
| 39 |
+
}
|
components/Diagnosis.tsx
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useState } from 'react'
|
| 4 |
+
import { AlertCircle, CheckCircle, AlertTriangle, Lightbulb, Shield, Calendar, Droplet } from 'lucide-react'
|
| 5 |
+
import { getDiseaseInfo } from '@/lib/diseaseInfo'
|
| 6 |
+
|
| 7 |
+
interface DiagnosisProps {
|
| 8 |
+
disease: string
|
| 9 |
+
crop: string
|
| 10 |
+
confidence: number
|
| 11 |
+
isHealthy: boolean
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export default function Diagnosis({ disease, crop, confidence, isHealthy }: DiagnosisProps) {
|
| 15 |
+
const [activeTab, setActiveTab] = useState<'assessment' | 'treatment' | 'prevention'>('assessment')
|
| 16 |
+
|
| 17 |
+
const diseaseInfo = getDiseaseInfo(disease, crop)
|
| 18 |
+
|
| 19 |
+
if (isHealthy) {
|
| 20 |
+
return (
|
| 21 |
+
<div className="mt-6 bg-gradient-to-br from-green-50 to-emerald-50 rounded-2xl border-2 border-green-300 p-8 shadow-xl">
|
| 22 |
+
<div className="flex items-start gap-4">
|
| 23 |
+
<div className="p-3 bg-green-100 rounded-xl">
|
| 24 |
+
<CheckCircle className="w-8 h-8 text-green-600" />
|
| 25 |
+
</div>
|
| 26 |
+
<div className="flex-1">
|
| 27 |
+
<h3 className="text-2xl font-extrabold text-green-900 mb-3">Diagnosis: Healthy Plant</h3>
|
| 28 |
+
<p className="text-green-800 mb-4">
|
| 29 |
+
Great news! Your {crop} plant appears to be healthy with no signs of disease detected.
|
| 30 |
+
</p>
|
| 31 |
+
<div className="bg-white/60 rounded-xl p-5 border border-green-200">
|
| 32 |
+
<h4 className="font-bold text-green-900 mb-3 flex items-center gap-2">
|
| 33 |
+
<Shield className="w-5 h-5" />
|
| 34 |
+
Maintenance Recommendations
|
| 35 |
+
</h4>
|
| 36 |
+
<ul className="space-y-2 text-green-800">
|
| 37 |
+
{diseaseInfo?.prevention.map((tip, index) => (
|
| 38 |
+
<li key={index} className="flex items-start gap-2">
|
| 39 |
+
<span className="text-green-600 mt-1">✓</span>
|
| 40 |
+
<span>{tip}</span>
|
| 41 |
+
</li>
|
| 42 |
+
))}
|
| 43 |
+
</ul>
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
)
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const getSeverityLevel = () => {
|
| 52 |
+
if (diseaseInfo?.severity === 'high') return 'Critical'
|
| 53 |
+
if (diseaseInfo?.severity === 'medium') return 'Moderate'
|
| 54 |
+
return 'Mild'
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const getSeverityColor = () => {
|
| 58 |
+
if (diseaseInfo?.severity === 'high') return 'text-red-600 bg-red-100 border-red-300'
|
| 59 |
+
if (diseaseInfo?.severity === 'medium') return 'text-orange-600 bg-orange-100 border-orange-300'
|
| 60 |
+
return 'text-yellow-600 bg-yellow-100 border-yellow-300'
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
const getConfidenceLevel = () => {
|
| 64 |
+
if (confidence >= 90) return { level: 'Very High', color: 'text-green-600' }
|
| 65 |
+
if (confidence >= 75) return { level: 'High', color: 'text-blue-600' }
|
| 66 |
+
if (confidence >= 60) return { level: 'Moderate', color: 'text-yellow-600' }
|
| 67 |
+
return { level: 'Low', color: 'text-orange-600' }
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
const confidenceInfo = getConfidenceLevel()
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
<div className="mt-6 bg-gradient-to-br from-white to-gray-50 rounded-2xl border-2 border-gray-200/50 p-4 shadow-xl sm:p-8">
|
| 74 |
+
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
| 75 |
+
<h3 className="flex items-center gap-2 text-2xl font-extrabold text-gray-900 sm:gap-3 sm:text-3xl">
|
| 76 |
+
<AlertCircle className="h-7 w-7 shrink-0 text-primary-600 sm:h-8 sm:w-8" />
|
| 77 |
+
<span className="min-w-0 leading-tight">Detailed Diagnosis</span>
|
| 78 |
+
</h3>
|
| 79 |
+
<div
|
| 80 |
+
className={`w-fit shrink-0 rounded-xl border-2 px-3 py-1.5 text-xs font-bold sm:px-4 sm:py-2 sm:text-sm ${getSeverityColor()}`}
|
| 81 |
+
>
|
| 82 |
+
{getSeverityLevel()} Severity
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
{/* Confidence & Assessment */}
|
| 87 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
| 88 |
+
<div className="bg-white rounded-xl p-5 border-2 border-gray-200 shadow-md">
|
| 89 |
+
<div className="flex items-center justify-between mb-2">
|
| 90 |
+
<span className="text-sm font-semibold text-gray-600 uppercase">Confidence Level</span>
|
| 91 |
+
<span className={`text-lg font-bold ${confidenceInfo.color}`}>
|
| 92 |
+
{confidenceInfo.level}
|
| 93 |
+
</span>
|
| 94 |
+
</div>
|
| 95 |
+
<div className="w-full bg-gray-200 rounded-full h-3">
|
| 96 |
+
<div
|
| 97 |
+
className={`h-3 rounded-full transition-all duration-500 ${
|
| 98 |
+
confidence >= 90 ? 'bg-green-500' :
|
| 99 |
+
confidence >= 75 ? 'bg-blue-500' :
|
| 100 |
+
confidence >= 60 ? 'bg-yellow-500' : 'bg-orange-500'
|
| 101 |
+
}`}
|
| 102 |
+
style={{ width: `${confidence}%` }}
|
| 103 |
+
/>
|
| 104 |
+
</div>
|
| 105 |
+
<p className="text-xs text-gray-500 mt-2">
|
| 106 |
+
AI confidence: {confidence.toFixed(1)}%
|
| 107 |
+
</p>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<div className="bg-white rounded-xl p-5 border-2 border-gray-200 shadow-md">
|
| 111 |
+
<div className="flex items-center gap-3 mb-2">
|
| 112 |
+
<Calendar className="w-5 h-5 text-primary-600" />
|
| 113 |
+
<span className="text-sm font-semibold text-gray-600 uppercase">Recommended Action</span>
|
| 114 |
+
</div>
|
| 115 |
+
<p className="text-lg font-bold text-gray-900">
|
| 116 |
+
{diseaseInfo?.severity === 'high' ? 'Immediate Treatment Required' :
|
| 117 |
+
diseaseInfo?.severity === 'medium' ? 'Monitor & Treat Soon' :
|
| 118 |
+
'Preventive Measures Recommended'}
|
| 119 |
+
</p>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
{/* Tabs — grid keeps all three inside the card on narrow screens */}
|
| 124 |
+
<div className="-mx-1 mb-6 border-b-2 border-gray-200 px-1 sm:mx-0 sm:px-0">
|
| 125 |
+
<div className="grid w-full grid-cols-3 gap-1 sm:gap-2">
|
| 126 |
+
{[
|
| 127 |
+
{ id: 'assessment', label: 'Assessment', icon: AlertCircle },
|
| 128 |
+
{ id: 'treatment', label: 'Treatment', icon: Droplet },
|
| 129 |
+
{ id: 'prevention', label: 'Prevention', icon: Shield },
|
| 130 |
+
].map(({ id, label, icon: Icon }) => (
|
| 131 |
+
<button
|
| 132 |
+
key={id}
|
| 133 |
+
type="button"
|
| 134 |
+
onClick={() => setActiveTab(id as 'assessment' | 'treatment' | 'prevention')}
|
| 135 |
+
className={`flex min-h-[3.25rem] min-w-0 touch-manipulation flex-col items-center justify-center gap-0.5 rounded-t-lg px-1.5 py-2 text-center text-[11px] font-semibold leading-tight transition-all duration-300 sm:flex-row sm:gap-2 sm:rounded-t-xl sm:px-4 sm:py-3 sm:text-sm ${
|
| 136 |
+
activeTab === id
|
| 137 |
+
? 'bg-primary-600 text-white shadow-lg'
|
| 138 |
+
: 'text-gray-600 hover:bg-gray-100 hover:text-primary-600'
|
| 139 |
+
}`}
|
| 140 |
+
>
|
| 141 |
+
<Icon className="h-4 w-4 shrink-0 sm:h-5 sm:w-5" />
|
| 142 |
+
<span className="break-words">{label}</span>
|
| 143 |
+
</button>
|
| 144 |
+
))}
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
{/* Tab Content */}
|
| 149 |
+
<div className="min-h-[300px]">
|
| 150 |
+
{activeTab === 'assessment' && (
|
| 151 |
+
<div className="space-y-6">
|
| 152 |
+
<div className="bg-white/80 rounded-xl p-6 border-2 border-gray-200">
|
| 153 |
+
<h4 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2">
|
| 154 |
+
<AlertTriangle className="w-6 h-6 text-orange-600" />
|
| 155 |
+
Disease Assessment
|
| 156 |
+
</h4>
|
| 157 |
+
<p className="text-gray-700 mb-4 leading-relaxed">
|
| 158 |
+
{diseaseInfo?.description || `Based on the AI analysis, your ${crop} plant shows signs of ${disease}.`}
|
| 159 |
+
</p>
|
| 160 |
+
|
| 161 |
+
{diseaseInfo?.symptoms && diseaseInfo.symptoms.length > 0 && (
|
| 162 |
+
<div className="mt-5">
|
| 163 |
+
<h5 className="font-bold text-gray-900 mb-3">Expected Symptoms:</h5>
|
| 164 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
| 165 |
+
{diseaseInfo.symptoms.map((symptom, index) => (
|
| 166 |
+
<div key={index} className="flex items-start gap-2 bg-orange-50 rounded-lg p-3 border border-orange-200">
|
| 167 |
+
<span className="text-orange-600 mt-1">•</span>
|
| 168 |
+
<span className="text-sm text-gray-700">{symptom}</span>
|
| 169 |
+
</div>
|
| 170 |
+
))}
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
)}
|
| 174 |
+
|
| 175 |
+
<div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
| 176 |
+
<div className="flex items-start gap-3">
|
| 177 |
+
<Lightbulb className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
| 178 |
+
<div>
|
| 179 |
+
<p className="text-sm font-semibold text-blue-900 mb-1">Diagnostic Tip</p>
|
| 180 |
+
<p className="text-sm text-blue-800">
|
| 181 |
+
Compare the visual symptoms on your plant with the expected symptoms listed above.
|
| 182 |
+
If symptoms match closely, the diagnosis is likely accurate. For confirmation,
|
| 183 |
+
consider consulting with an agricultural extension service.
|
| 184 |
+
</p>
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
)}
|
| 191 |
+
|
| 192 |
+
{activeTab === 'treatment' && (
|
| 193 |
+
<div className="space-y-6">
|
| 194 |
+
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl p-6 border-2 border-blue-200">
|
| 195 |
+
<h4 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2">
|
| 196 |
+
<Droplet className="w-6 h-6 text-blue-600" />
|
| 197 |
+
Treatment Plan
|
| 198 |
+
</h4>
|
| 199 |
+
|
| 200 |
+
{diseaseInfo?.treatment && diseaseInfo.treatment.length > 0 ? (
|
| 201 |
+
<div className="space-y-4">
|
| 202 |
+
<p className="text-gray-700 font-semibold mb-4">
|
| 203 |
+
Immediate treatment steps for {disease}:
|
| 204 |
+
</p>
|
| 205 |
+
<div className="space-y-3">
|
| 206 |
+
{diseaseInfo.treatment.map((step, index) => (
|
| 207 |
+
<div key={index} className="bg-white rounded-lg p-4 border border-blue-200 shadow-sm">
|
| 208 |
+
<div className="flex items-start gap-3">
|
| 209 |
+
<div className="flex-shrink-0 w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold">
|
| 210 |
+
{index + 1}
|
| 211 |
+
</div>
|
| 212 |
+
<p className="text-gray-800 flex-1">{step}</p>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
))}
|
| 216 |
+
</div>
|
| 217 |
+
</div>
|
| 218 |
+
) : (
|
| 219 |
+
<p className="text-gray-700">
|
| 220 |
+
Treatment recommendations are being prepared. Please consult with an agricultural expert
|
| 221 |
+
for specific treatment options for {disease} in {crop}.
|
| 222 |
+
</p>
|
| 223 |
+
)}
|
| 224 |
+
|
| 225 |
+
<div className="mt-6 p-4 bg-yellow-50 rounded-lg border border-yellow-300">
|
| 226 |
+
<div className="flex items-start gap-3">
|
| 227 |
+
<AlertTriangle className="w-5 h-5 text-yellow-700 flex-shrink-0 mt-0.5" />
|
| 228 |
+
<div>
|
| 229 |
+
<p className="text-sm font-bold text-yellow-900 mb-1">Important</p>
|
| 230 |
+
<p className="text-sm text-yellow-800">
|
| 231 |
+
Always follow label instructions when applying any treatments. Consider environmental
|
| 232 |
+
impact and use Integrated Pest Management (IPM) practices. For severe cases,
|
| 233 |
+
consult with certified agricultural professionals.
|
| 234 |
+
</p>
|
| 235 |
+
</div>
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
</div>
|
| 239 |
+
</div>
|
| 240 |
+
)}
|
| 241 |
+
|
| 242 |
+
{activeTab === 'prevention' && (
|
| 243 |
+
<div className="space-y-6">
|
| 244 |
+
<div className="bg-gradient-to-br from-green-50 to-emerald-50 rounded-xl p-6 border-2 border-green-200">
|
| 245 |
+
<h4 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2">
|
| 246 |
+
<Shield className="w-6 h-6 text-green-600" />
|
| 247 |
+
Prevention Strategy
|
| 248 |
+
</h4>
|
| 249 |
+
|
| 250 |
+
{diseaseInfo?.prevention && diseaseInfo.prevention.length > 0 ? (
|
| 251 |
+
<div className="space-y-4">
|
| 252 |
+
<p className="text-gray-700 font-semibold mb-4">
|
| 253 |
+
Long-term prevention measures to protect your {crop} crops:
|
| 254 |
+
</p>
|
| 255 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 256 |
+
{diseaseInfo.prevention.map((prevention, index) => (
|
| 257 |
+
<div key={index} className="bg-white rounded-lg p-4 border border-green-200 shadow-sm flex items-start gap-3">
|
| 258 |
+
<CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
| 259 |
+
<p className="text-gray-800 text-sm">{prevention}</p>
|
| 260 |
+
</div>
|
| 261 |
+
))}
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
) : (
|
| 265 |
+
<p className="text-gray-700">
|
| 266 |
+
Prevention strategies are being developed. General best practices include crop rotation,
|
| 267 |
+
proper spacing, and regular monitoring.
|
| 268 |
+
</p>
|
| 269 |
+
)}
|
| 270 |
+
|
| 271 |
+
<div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
| 272 |
+
<div className="flex items-start gap-3">
|
| 273 |
+
<Calendar className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
| 274 |
+
<div>
|
| 275 |
+
<p className="text-sm font-bold text-blue-900 mb-1">Monitoring Schedule</p>
|
| 276 |
+
<p className="text-sm text-blue-800">
|
| 277 |
+
Regular monitoring is key to early detection. Check your {crop} fields weekly,
|
| 278 |
+
especially during critical growth stages. Early intervention is more effective
|
| 279 |
+
and cost-efficient than treating advanced disease.
|
| 280 |
+
</p>
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
</div>
|
| 286 |
+
)}
|
| 287 |
+
</div>
|
| 288 |
+
|
| 289 |
+
{/* Action Summary */}
|
| 290 |
+
<div className="mt-6 p-5 bg-gradient-to-r from-primary-50 to-blue-50 rounded-xl border-2 border-primary-200">
|
| 291 |
+
<h4 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
| 292 |
+
<Lightbulb className="w-5 h-5 text-primary-600" />
|
| 293 |
+
Quick Action Summary
|
| 294 |
+
</h4>
|
| 295 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
| 296 |
+
<div>
|
| 297 |
+
<p className="font-semibold text-gray-700 mb-1">1. Immediate</p>
|
| 298 |
+
<p className="text-gray-600">
|
| 299 |
+
{diseaseInfo?.severity === 'high'
|
| 300 |
+
? 'Apply treatment within 24-48 hours'
|
| 301 |
+
: diseaseInfo?.severity === 'medium'
|
| 302 |
+
? 'Monitor closely and prepare treatment'
|
| 303 |
+
: 'Implement preventive measures'}
|
| 304 |
+
</p>
|
| 305 |
+
</div>
|
| 306 |
+
<div>
|
| 307 |
+
<p className="font-semibold text-gray-700 mb-1">2. Short-term</p>
|
| 308 |
+
<p className="text-gray-600">
|
| 309 |
+
Follow treatment plan and monitor progress over next 7-14 days
|
| 310 |
+
</p>
|
| 311 |
+
</div>
|
| 312 |
+
<div>
|
| 313 |
+
<p className="font-semibold text-gray-700 mb-1">3. Long-term</p>
|
| 314 |
+
<p className="text-gray-600">
|
| 315 |
+
Implement prevention strategies to avoid future occurrences
|
| 316 |
+
</p>
|
| 317 |
+
</div>
|
| 318 |
+
</div>
|
| 319 |
+
</div>
|
| 320 |
+
</div>
|
| 321 |
+
)
|
| 322 |
+
}
|
components/DiseaseInfo.tsx
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { getDiseaseInfo } from '@/lib/diseaseInfo'
|
| 4 |
+
|
| 5 |
+
interface DiseaseInfoProps {
|
| 6 |
+
diseaseName: string
|
| 7 |
+
crop: string
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export default function DiseaseInfo({ diseaseName, crop }: DiseaseInfoProps) {
|
| 11 |
+
const info = getDiseaseInfo(diseaseName, crop)
|
| 12 |
+
|
| 13 |
+
if (!info) return null
|
| 14 |
+
|
| 15 |
+
const severityColors = {
|
| 16 |
+
low: 'bg-green-100 text-green-800 border-green-300',
|
| 17 |
+
medium: 'bg-yellow-100 text-yellow-800 border-yellow-300',
|
| 18 |
+
high: 'bg-red-100 text-red-800 border-red-300'
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<div className="mt-6 bg-gradient-to-br from-white to-gray-50 rounded-2xl border-2 border-gray-200/50 p-8 shadow-xl">
|
| 23 |
+
<h3 className="text-3xl font-extrabold text-gray-900 mb-6 flex items-center gap-3">
|
| 24 |
+
<svg className="w-8 h-8 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 25 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
| 26 |
+
</svg>
|
| 27 |
+
Disease Information
|
| 28 |
+
</h3>
|
| 29 |
+
|
| 30 |
+
{/* Description */}
|
| 31 |
+
<div className="mb-6">
|
| 32 |
+
<h4 className="text-lg font-semibold text-gray-700 mb-2">Description</h4>
|
| 33 |
+
<p className="text-gray-600">{info.description}</p>
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
{/* Severity Badge */}
|
| 37 |
+
{!info.name.toLowerCase().includes('healthy') && (
|
| 38 |
+
<div className="mb-6">
|
| 39 |
+
<span className={`inline-flex items-center gap-2 px-5 py-2.5 rounded-xl border-2 font-bold text-sm shadow-md ${severityColors[info.severity]}`}>
|
| 40 |
+
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
| 41 |
+
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
| 42 |
+
</svg>
|
| 43 |
+
Severity: {info.severity.toUpperCase()}
|
| 44 |
+
</span>
|
| 45 |
+
</div>
|
| 46 |
+
)}
|
| 47 |
+
|
| 48 |
+
{/* Symptoms */}
|
| 49 |
+
{info.symptoms.length > 0 && (
|
| 50 |
+
<div className="mb-6 bg-white/60 rounded-xl p-5 border border-gray-200">
|
| 51 |
+
<h4 className="text-xl font-bold text-gray-800 mb-4 flex items-center gap-2">
|
| 52 |
+
<svg className="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 53 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
| 54 |
+
</svg>
|
| 55 |
+
Symptoms
|
| 56 |
+
</h4>
|
| 57 |
+
<ul className="space-y-3 text-gray-700">
|
| 58 |
+
{info.symptoms.map((symptom, index) => (
|
| 59 |
+
<li key={index} className="flex items-start gap-3">
|
| 60 |
+
<span className="text-red-500 mt-1">•</span>
|
| 61 |
+
<span>{symptom}</span>
|
| 62 |
+
</li>
|
| 63 |
+
))}
|
| 64 |
+
</ul>
|
| 65 |
+
</div>
|
| 66 |
+
)}
|
| 67 |
+
|
| 68 |
+
{/* Treatment */}
|
| 69 |
+
{info.treatment.length > 0 && (
|
| 70 |
+
<div className="mb-6 bg-white/60 rounded-xl p-5 border border-gray-200">
|
| 71 |
+
<h4 className="text-xl font-bold text-gray-800 mb-4 flex items-center gap-2">
|
| 72 |
+
<svg className="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 73 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 74 |
+
</svg>
|
| 75 |
+
Treatment
|
| 76 |
+
</h4>
|
| 77 |
+
<ul className="space-y-3 text-gray-700">
|
| 78 |
+
{info.treatment.map((treatment, index) => (
|
| 79 |
+
<li key={index} className="flex items-start gap-3">
|
| 80 |
+
<span className="text-blue-500 mt-1">•</span>
|
| 81 |
+
<span>{treatment}</span>
|
| 82 |
+
</li>
|
| 83 |
+
))}
|
| 84 |
+
</ul>
|
| 85 |
+
</div>
|
| 86 |
+
)}
|
| 87 |
+
|
| 88 |
+
{/* Prevention */}
|
| 89 |
+
{info.prevention.length > 0 && (
|
| 90 |
+
<div className="mb-6 bg-white/60 rounded-xl p-5 border border-gray-200">
|
| 91 |
+
<h4 className="text-xl font-bold text-gray-800 mb-4 flex items-center gap-2">
|
| 92 |
+
<svg className="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 93 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
| 94 |
+
</svg>
|
| 95 |
+
Prevention
|
| 96 |
+
</h4>
|
| 97 |
+
<ul className="space-y-3 text-gray-700">
|
| 98 |
+
{info.prevention.map((prevention, index) => (
|
| 99 |
+
<li key={index} className="flex items-start gap-3">
|
| 100 |
+
<span className="text-green-500 mt-1">•</span>
|
| 101 |
+
<span>{prevention}</span>
|
| 102 |
+
</li>
|
| 103 |
+
))}
|
| 104 |
+
</ul>
|
| 105 |
+
</div>
|
| 106 |
+
)}
|
| 107 |
+
|
| 108 |
+
{/* Disclaimer */}
|
| 109 |
+
<div className="mt-6 p-5 bg-gradient-to-r from-blue-50 to-indigo-50 border-2 border-blue-200 rounded-xl shadow-md">
|
| 110 |
+
<div className="flex items-start gap-3">
|
| 111 |
+
<svg className="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 112 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 113 |
+
</svg>
|
| 114 |
+
<p className="text-sm text-blue-900 font-medium">
|
| 115 |
+
<strong>Note:</strong> This information is for reference only. Always consult with agricultural experts or extension services for specific treatment recommendations for your region.
|
| 116 |
+
</p>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
)
|
| 121 |
+
}
|
components/ExportResults.tsx
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
interface ExportResultsProps {
|
| 4 |
+
prediction: any
|
| 5 |
+
crop: string
|
| 6 |
+
imageUrl: string | null
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export default function ExportResults({ prediction, crop, imageUrl }: ExportResultsProps) {
|
| 10 |
+
const exportToJSON = () => {
|
| 11 |
+
const data = {
|
| 12 |
+
crop,
|
| 13 |
+
disease: prediction.disease,
|
| 14 |
+
confidence: prediction.confidence,
|
| 15 |
+
isHealthy: prediction.is_healthy,
|
| 16 |
+
timestamp: new Date().toISOString(),
|
| 17 |
+
allPredictions: prediction.all_predictions
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
| 21 |
+
const url = URL.createObjectURL(blob)
|
| 22 |
+
const a = document.createElement('a')
|
| 23 |
+
a.href = url
|
| 24 |
+
a.download = `cropintel_${crop}_${Date.now()}.json`
|
| 25 |
+
document.body.appendChild(a)
|
| 26 |
+
a.click()
|
| 27 |
+
document.body.removeChild(a)
|
| 28 |
+
URL.revokeObjectURL(url)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const exportToCSV = () => {
|
| 32 |
+
const headers = ['Disease', 'Confidence (%)', 'Is Healthy']
|
| 33 |
+
const rows = prediction.all_predictions.map((p: any) => [
|
| 34 |
+
p.disease,
|
| 35 |
+
p.confidence.toFixed(2),
|
| 36 |
+
prediction.is_healthy ? 'Yes' : 'No'
|
| 37 |
+
])
|
| 38 |
+
|
| 39 |
+
const csv = [
|
| 40 |
+
headers.join(','),
|
| 41 |
+
...rows.map((row: any[]) => row.join(','))
|
| 42 |
+
].join('\n')
|
| 43 |
+
|
| 44 |
+
const blob = new Blob([csv], { type: 'text/csv' })
|
| 45 |
+
const url = URL.createObjectURL(blob)
|
| 46 |
+
const a = document.createElement('a')
|
| 47 |
+
a.href = url
|
| 48 |
+
a.download = `cropintel_${crop}_${Date.now()}.csv`
|
| 49 |
+
document.body.appendChild(a)
|
| 50 |
+
a.click()
|
| 51 |
+
document.body.removeChild(a)
|
| 52 |
+
URL.revokeObjectURL(url)
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const shareResults = async () => {
|
| 56 |
+
const text = `CropIntel Analysis Results:\nCrop: ${crop}\nDisease: ${prediction.disease}\nConfidence: ${prediction.confidence.toFixed(1)}%\n\nAnalyzed with CropIntel AI`
|
| 57 |
+
|
| 58 |
+
if (navigator.share) {
|
| 59 |
+
try {
|
| 60 |
+
await navigator.share({
|
| 61 |
+
title: 'CropIntel Analysis Results',
|
| 62 |
+
text: text,
|
| 63 |
+
})
|
| 64 |
+
} catch (err) {
|
| 65 |
+
console.error('Error sharing:', err)
|
| 66 |
+
}
|
| 67 |
+
} else {
|
| 68 |
+
// Fallback: copy to clipboard
|
| 69 |
+
navigator.clipboard.writeText(text)
|
| 70 |
+
alert('Results copied to clipboard!')
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
return (
|
| 75 |
+
<div className="mt-6 bg-gradient-to-br from-white to-gray-50 rounded-2xl border-2 border-gray-200/50 p-6 shadow-xl">
|
| 76 |
+
<h3 className="text-2xl font-extrabold text-gray-900 mb-5 flex items-center gap-3">
|
| 77 |
+
<svg className="w-7 h-7 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 78 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
| 79 |
+
</svg>
|
| 80 |
+
Export Results
|
| 81 |
+
</h3>
|
| 82 |
+
<div className="flex flex-wrap gap-4">
|
| 83 |
+
<button
|
| 84 |
+
onClick={exportToJSON}
|
| 85 |
+
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl hover:from-blue-700 hover:to-blue-800 transition-all duration-300 flex items-center gap-2 font-semibold shadow-lg hover:shadow-xl transform hover:scale-105"
|
| 86 |
+
>
|
| 87 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 88 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
| 89 |
+
</svg>
|
| 90 |
+
Export JSON
|
| 91 |
+
</button>
|
| 92 |
+
<button
|
| 93 |
+
onClick={exportToCSV}
|
| 94 |
+
className="px-6 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition-all duration-300 flex items-center gap-2 font-semibold shadow-lg hover:shadow-xl transform hover:scale-105"
|
| 95 |
+
>
|
| 96 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 97 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
| 98 |
+
</svg>
|
| 99 |
+
Export CSV
|
| 100 |
+
</button>
|
| 101 |
+
<button
|
| 102 |
+
onClick={shareResults}
|
| 103 |
+
className="px-6 py-3 bg-gradient-to-r from-purple-600 to-purple-700 text-white rounded-xl hover:from-purple-700 hover:to-purple-800 transition-all duration-300 flex items-center gap-2 font-semibold shadow-lg hover:shadow-xl transform hover:scale-105"
|
| 104 |
+
>
|
| 105 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 106 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
| 107 |
+
</svg>
|
| 108 |
+
Share
|
| 109 |
+
</button>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
)
|
| 113 |
+
}
|
components/FarmerRegistration.tsx
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from 'react'
|
| 4 |
+
import { createPortal } from 'react-dom'
|
| 5 |
+
import { MapPin, Save, X } from 'lucide-react'
|
| 6 |
+
import { mockValidateUsdaFarmCode } from '@/lib/farmerProfile'
|
| 7 |
+
|
| 8 |
+
interface FarmerRegistrationProps {
|
| 9 |
+
onRegister: (location: {
|
| 10 |
+
lat: number
|
| 11 |
+
lng: number
|
| 12 |
+
crops: string[]
|
| 13 |
+
name: string
|
| 14 |
+
email?: string
|
| 15 |
+
usdaFarmCode?: string
|
| 16 |
+
verifiedFarmer: boolean
|
| 17 |
+
}) => void
|
| 18 |
+
crops: string[]
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export default function FarmerRegistration({ onRegister, crops }: FarmerRegistrationProps) {
|
| 22 |
+
const [mounted, setMounted] = useState(false)
|
| 23 |
+
const [isOpen, setIsOpen] = useState(false)
|
| 24 |
+
const [formData, setFormData] = useState({
|
| 25 |
+
name: '',
|
| 26 |
+
email: '',
|
| 27 |
+
lat: '',
|
| 28 |
+
lng: '',
|
| 29 |
+
usdaFarmCode: '',
|
| 30 |
+
selectedCrops: [] as string[],
|
| 31 |
+
})
|
| 32 |
+
|
| 33 |
+
useEffect(() => setMounted(true), [])
|
| 34 |
+
|
| 35 |
+
useEffect(() => {
|
| 36 |
+
if (!isOpen) return
|
| 37 |
+
const prev = document.body.style.overflow
|
| 38 |
+
document.body.style.overflow = 'hidden'
|
| 39 |
+
return () => {
|
| 40 |
+
document.body.style.overflow = prev
|
| 41 |
+
}
|
| 42 |
+
}, [isOpen])
|
| 43 |
+
|
| 44 |
+
useEffect(() => {
|
| 45 |
+
if (!isOpen) return
|
| 46 |
+
const onKey = (e: KeyboardEvent) => {
|
| 47 |
+
if (e.key === 'Escape') setIsOpen(false)
|
| 48 |
+
}
|
| 49 |
+
window.addEventListener('keydown', onKey)
|
| 50 |
+
return () => window.removeEventListener('keydown', onKey)
|
| 51 |
+
}, [isOpen])
|
| 52 |
+
|
| 53 |
+
const handleGetLocation = () => {
|
| 54 |
+
if (navigator.geolocation) {
|
| 55 |
+
navigator.geolocation.getCurrentPosition(
|
| 56 |
+
(position) => {
|
| 57 |
+
setFormData((prev) => ({
|
| 58 |
+
...prev,
|
| 59 |
+
lat: position.coords.latitude.toFixed(6),
|
| 60 |
+
lng: position.coords.longitude.toFixed(6),
|
| 61 |
+
}))
|
| 62 |
+
},
|
| 63 |
+
(error) => {
|
| 64 |
+
alert('Unable to get your location. Please enter it manually.')
|
| 65 |
+
console.error('Geolocation error:', error)
|
| 66 |
+
}
|
| 67 |
+
)
|
| 68 |
+
} else {
|
| 69 |
+
alert('Geolocation is not supported by your browser.')
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
const handleCropToggle = (crop: string) => {
|
| 74 |
+
setFormData((prev) => ({
|
| 75 |
+
...prev,
|
| 76 |
+
selectedCrops: prev.selectedCrops.includes(crop)
|
| 77 |
+
? prev.selectedCrops.filter((c) => c !== crop)
|
| 78 |
+
: [...prev.selectedCrops, crop],
|
| 79 |
+
}))
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
const handleSubmit = () => {
|
| 83 |
+
const missing: string[] = []
|
| 84 |
+
if (!formData.name.trim()) missing.push('Farm name (green section at top of form)')
|
| 85 |
+
const latOk = formData.lat.trim() !== '' && !Number.isNaN(parseFloat(formData.lat))
|
| 86 |
+
const lngOk = formData.lng.trim() !== '' && !Number.isNaN(parseFloat(formData.lng))
|
| 87 |
+
if (!latOk) missing.push('Latitude')
|
| 88 |
+
if (!lngOk) missing.push('Longitude')
|
| 89 |
+
if (formData.selectedCrops.length === 0) missing.push('At least one crop')
|
| 90 |
+
|
| 91 |
+
if (missing.length > 0) {
|
| 92 |
+
alert(`Please complete the following:\n\n• ${missing.join('\n• ')}`)
|
| 93 |
+
return
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
const code = formData.usdaFarmCode.trim()
|
| 97 |
+
const verifiedFarmer = code.length > 0 ? mockValidateUsdaFarmCode(code) : false
|
| 98 |
+
|
| 99 |
+
onRegister({
|
| 100 |
+
lat: parseFloat(formData.lat),
|
| 101 |
+
lng: parseFloat(formData.lng),
|
| 102 |
+
crops: formData.selectedCrops,
|
| 103 |
+
name: formData.name,
|
| 104 |
+
email: formData.email.trim() || undefined,
|
| 105 |
+
usdaFarmCode: code || undefined,
|
| 106 |
+
verifiedFarmer,
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
setIsOpen(false)
|
| 110 |
+
setFormData({
|
| 111 |
+
name: '',
|
| 112 |
+
email: '',
|
| 113 |
+
lat: '',
|
| 114 |
+
lng: '',
|
| 115 |
+
usdaFarmCode: '',
|
| 116 |
+
selectedCrops: [],
|
| 117 |
+
})
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
return (
|
| 121 |
+
<>
|
| 122 |
+
<button
|
| 123 |
+
onClick={() => setIsOpen(true)}
|
| 124 |
+
className="px-4 py-2 bg-primary-700 hover:bg-primary-800 text-white rounded-xl font-semibold transition-colors flex items-center gap-2 shadow-sm"
|
| 125 |
+
>
|
| 126 |
+
<MapPin className="w-4 h-4" />
|
| 127 |
+
Register Your Farm
|
| 128 |
+
</button>
|
| 129 |
+
|
| 130 |
+
{mounted &&
|
| 131 |
+
isOpen &&
|
| 132 |
+
createPortal(
|
| 133 |
+
<div
|
| 134 |
+
key="farm-registration-fullpage"
|
| 135 |
+
className="fixed inset-0 z-[9999] flex h-[100dvh] w-screen flex-col overflow-hidden bg-white"
|
| 136 |
+
role="dialog"
|
| 137 |
+
aria-modal="true"
|
| 138 |
+
aria-labelledby="farm-registration-title"
|
| 139 |
+
>
|
| 140 |
+
<div className="flex items-center justify-between gap-4 px-5 sm:px-10 lg:px-16 py-4 sm:py-6 border-b border-slate-200 flex-shrink-0 bg-white">
|
| 141 |
+
<h2
|
| 142 |
+
id="farm-registration-title"
|
| 143 |
+
className="text-xl sm:text-2xl font-bold text-slate-900 flex items-center gap-3 min-w-0"
|
| 144 |
+
>
|
| 145 |
+
<MapPin className="w-7 h-7 text-primary-600 shrink-0" />
|
| 146 |
+
<span>Register your farm</span>
|
| 147 |
+
</h2>
|
| 148 |
+
<button
|
| 149 |
+
type="button"
|
| 150 |
+
onClick={() => setIsOpen(false)}
|
| 151 |
+
className="text-slate-500 hover:text-slate-900 transition-colors p-3 hover:bg-slate-100 rounded-xl shrink-0 border border-slate-200"
|
| 152 |
+
aria-label="Close"
|
| 153 |
+
>
|
| 154 |
+
<X className="w-6 h-6" />
|
| 155 |
+
</button>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
<div className="px-5 sm:px-10 lg:px-16 py-5 sm:py-6 bg-primary-50/90 border-b border-primary-100 flex-shrink-0">
|
| 159 |
+
<label className="block text-base font-bold text-slate-800 mb-2" htmlFor="farm-registration-name">
|
| 160 |
+
Farm name <span className="text-red-600">*</span>
|
| 161 |
+
</label>
|
| 162 |
+
<input
|
| 163 |
+
id="farm-registration-name"
|
| 164 |
+
type="text"
|
| 165 |
+
value={formData.name}
|
| 166 |
+
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
| 167 |
+
placeholder="e.g., Smith Family Farm"
|
| 168 |
+
autoComplete="organization"
|
| 169 |
+
className="w-full max-w-3xl px-5 py-4 border-2 border-primary-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-400 focus:border-primary-600 bg-white text-lg"
|
| 170 |
+
/>
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
<div className="flex-1 overflow-y-auto overscroll-contain px-5 sm:px-10 lg:px-16 py-6 sm:py-8 space-y-6 sm:space-y-8 min-h-0">
|
| 174 |
+
<div>
|
| 175 |
+
<label className="block text-base font-semibold text-slate-700 mb-2">
|
| 176 |
+
Email (optional)
|
| 177 |
+
</label>
|
| 178 |
+
<input
|
| 179 |
+
type="email"
|
| 180 |
+
value={formData.email}
|
| 181 |
+
onChange={(e) => setFormData((prev) => ({ ...prev, email: e.target.value }))}
|
| 182 |
+
placeholder="farmer@example.com"
|
| 183 |
+
className="w-full max-w-3xl px-5 py-4 border-2 border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-300 focus:border-primary-600 transition-all text-lg"
|
| 184 |
+
/>
|
| 185 |
+
</div>
|
| 186 |
+
|
| 187 |
+
<div>
|
| 188 |
+
<label className="block text-base font-semibold text-slate-700 mb-2">
|
| 189 |
+
USDA farm / tract code (optional)
|
| 190 |
+
</label>
|
| 191 |
+
<input
|
| 192 |
+
type="text"
|
| 193 |
+
value={formData.usdaFarmCode}
|
| 194 |
+
onChange={(e) => setFormData((prev) => ({ ...prev, usdaFarmCode: e.target.value }))}
|
| 195 |
+
placeholder="e.g., 12345-67890"
|
| 196 |
+
className="w-full max-w-3xl px-5 py-4 border-2 border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-300 focus:border-primary-600 transition-all font-mono text-lg"
|
| 197 |
+
/>
|
| 198 |
+
<p className="text-sm text-slate-600 mt-2 max-w-3xl">
|
| 199 |
+
If provided and format looks valid, you'll be marked as a <strong>Verified farmer</strong>{' '}
|
| 200 |
+
(demo validation only).
|
| 201 |
+
</p>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
<div>
|
| 205 |
+
<label className="block text-base font-semibold text-slate-700 mb-2">
|
| 206 |
+
Location <span className="text-red-500">*</span>
|
| 207 |
+
</label>
|
| 208 |
+
<div className="flex flex-col sm:flex-row gap-3 mb-3 max-w-3xl">
|
| 209 |
+
<input
|
| 210 |
+
type="number"
|
| 211 |
+
step="any"
|
| 212 |
+
value={formData.lat}
|
| 213 |
+
onChange={(e) => setFormData((prev) => ({ ...prev, lat: e.target.value }))}
|
| 214 |
+
placeholder="Latitude"
|
| 215 |
+
className="flex-1 min-w-0 px-5 py-4 border-2 border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-300 focus:border-primary-600 transition-all text-lg"
|
| 216 |
+
/>
|
| 217 |
+
<input
|
| 218 |
+
type="number"
|
| 219 |
+
step="any"
|
| 220 |
+
value={formData.lng}
|
| 221 |
+
onChange={(e) => setFormData((prev) => ({ ...prev, lng: e.target.value }))}
|
| 222 |
+
placeholder="Longitude"
|
| 223 |
+
className="flex-1 min-w-0 px-5 py-4 border-2 border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-300 focus:border-primary-600 transition-all text-lg"
|
| 224 |
+
/>
|
| 225 |
+
</div>
|
| 226 |
+
<button
|
| 227 |
+
type="button"
|
| 228 |
+
onClick={handleGetLocation}
|
| 229 |
+
className="w-full max-w-3xl px-5 py-4 bg-primary-100 hover:bg-primary-200 text-primary-900 rounded-xl font-semibold transition-colors flex items-center justify-center gap-2 text-lg"
|
| 230 |
+
>
|
| 231 |
+
<MapPin className="w-5 h-5" />
|
| 232 |
+
Use My Current Location
|
| 233 |
+
</button>
|
| 234 |
+
</div>
|
| 235 |
+
|
| 236 |
+
<div>
|
| 237 |
+
<label className="block text-base font-semibold text-slate-700 mb-3">
|
| 238 |
+
Crops you grow <span className="text-red-500">*</span>
|
| 239 |
+
</label>
|
| 240 |
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4 max-w-4xl">
|
| 241 |
+
{crops.map((crop) => (
|
| 242 |
+
<button
|
| 243 |
+
type="button"
|
| 244 |
+
key={crop}
|
| 245 |
+
onClick={() => handleCropToggle(crop)}
|
| 246 |
+
className={`px-4 py-5 sm:py-6 rounded-xl font-bold transition-all border-2 text-lg ${
|
| 247 |
+
formData.selectedCrops.includes(crop)
|
| 248 |
+
? 'bg-primary-700 text-white border-primary-700 shadow-md'
|
| 249 |
+
: 'bg-slate-100 text-slate-800 border-slate-300 hover:border-primary-400'
|
| 250 |
+
}`}
|
| 251 |
+
>
|
| 252 |
+
{crop.charAt(0).toUpperCase() + crop.slice(1)}
|
| 253 |
+
</button>
|
| 254 |
+
))}
|
| 255 |
+
</div>
|
| 256 |
+
</div>
|
| 257 |
+
|
| 258 |
+
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 sm:p-5 max-w-3xl">
|
| 259 |
+
<p className="text-sm sm:text-base text-primary-900">
|
| 260 |
+
<strong>Note:</strong> You'll receive alerts for disease outbreaks within 250 miles of your registered location for the crops you select.
|
| 261 |
+
</p>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
+
<div className="flex-shrink-0 border-t border-slate-200 px-5 sm:px-10 lg:px-16 py-5 sm:py-6 bg-slate-50">
|
| 266 |
+
<button
|
| 267 |
+
type="button"
|
| 268 |
+
onClick={handleSubmit}
|
| 269 |
+
className="w-full max-w-3xl mx-auto bg-gradient-to-r from-primary-700 to-primary-800 hover:from-primary-800 hover:to-primary-900 text-white font-bold py-4 sm:py-5 px-8 rounded-xl transition-all flex items-center justify-center gap-3 shadow-md text-lg"
|
| 270 |
+
>
|
| 271 |
+
<Save className="w-6 h-6" />
|
| 272 |
+
Register Farm
|
| 273 |
+
</button>
|
| 274 |
+
</div>
|
| 275 |
+
</div>,
|
| 276 |
+
document.body
|
| 277 |
+
)}
|
| 278 |
+
</>
|
| 279 |
+
)
|
| 280 |
+
}
|
components/FarmerVerificationBadge.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { BadgeCheck, User } from 'lucide-react'
|
| 4 |
+
|
| 5 |
+
type Props = {
|
| 6 |
+
verified: boolean
|
| 7 |
+
compact?: boolean
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export default function FarmerVerificationBadge({ verified, compact }: Props) {
|
| 11 |
+
if (verified) {
|
| 12 |
+
return (
|
| 13 |
+
<span
|
| 14 |
+
className={`inline-flex items-center gap-1.5 rounded-full border font-semibold bg-emerald-50 text-emerald-900 border-emerald-200 ${
|
| 15 |
+
compact ? 'px-2 py-0.5 text-xs' : 'px-3 py-1 text-sm'
|
| 16 |
+
}`}
|
| 17 |
+
>
|
| 18 |
+
<BadgeCheck className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
|
| 19 |
+
Verified farmer
|
| 20 |
+
</span>
|
| 21 |
+
)
|
| 22 |
+
}
|
| 23 |
+
return (
|
| 24 |
+
<span
|
| 25 |
+
className={`inline-flex items-center gap-1.5 rounded-full border font-semibold bg-slate-100 text-slate-700 border-slate-200 ${
|
| 26 |
+
compact ? 'px-2 py-0.5 text-xs' : 'px-3 py-1 text-sm'
|
| 27 |
+
}`}
|
| 28 |
+
>
|
| 29 |
+
<User className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
|
| 30 |
+
Unverified
|
| 31 |
+
</span>
|
| 32 |
+
)
|
| 33 |
+
}
|
components/GoogleMap.tsx
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useLoadScript, GoogleMap, Marker, InfoWindow } from '@react-google-maps/api'
|
| 4 |
+
import { useState, useCallback, useEffect } from 'react'
|
| 5 |
+
import { MapPin } from 'lucide-react'
|
| 6 |
+
import type { OutbreakReport } from '@/lib/outbreakReport'
|
| 7 |
+
|
| 8 |
+
const libraries: ('places' | 'drawing' | 'geometry' | 'visualization')[] = ['places']
|
| 9 |
+
|
| 10 |
+
interface GoogleMapProps {
|
| 11 |
+
reports?: OutbreakReport[]
|
| 12 |
+
onMapClick?: (lat: number, lng: number) => void
|
| 13 |
+
center?: { lat: number; lng: number }
|
| 14 |
+
zoom?: number
|
| 15 |
+
/** When false, hides the floating “click map” hint (parent may show its own). Default true. */
|
| 16 |
+
showMapClickHint?: boolean
|
| 17 |
+
/** Called when the Google Map instance is ready (e.g. to trigger resize after layout changes). */
|
| 18 |
+
onMapReady?: (map: google.maps.Map) => void
|
| 19 |
+
/** Maps API fullscreen control; set false when the parent provides its own fullscreen. Default true. */
|
| 20 |
+
fullscreenControl?: boolean
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const mapContainerStyle = {
|
| 24 |
+
width: '100%',
|
| 25 |
+
height: '100%',
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const defaultCenter = {
|
| 29 |
+
lat: 39.8283, // Center of US
|
| 30 |
+
lng: -98.5795,
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const defaultZoom = 4
|
| 34 |
+
|
| 35 |
+
export default function GoogleMapComponent({
|
| 36 |
+
reports = [],
|
| 37 |
+
onMapClick,
|
| 38 |
+
center = defaultCenter,
|
| 39 |
+
zoom = defaultZoom,
|
| 40 |
+
showMapClickHint = true,
|
| 41 |
+
onMapReady,
|
| 42 |
+
fullscreenControl = true,
|
| 43 |
+
}: GoogleMapProps) {
|
| 44 |
+
const [selectedReport, setSelectedReport] = useState<OutbreakReport | null>(null)
|
| 45 |
+
const [mapCenter, setMapCenter] = useState(center)
|
| 46 |
+
const [loadingTimeout, setLoadingTimeout] = useState(false)
|
| 47 |
+
|
| 48 |
+
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ''
|
| 49 |
+
|
| 50 |
+
const { isLoaded, loadError } = useLoadScript({
|
| 51 |
+
googleMapsApiKey: apiKey,
|
| 52 |
+
libraries,
|
| 53 |
+
})
|
| 54 |
+
|
| 55 |
+
// Add timeout to prevent infinite loading
|
| 56 |
+
useEffect(() => {
|
| 57 |
+
if (!isLoaded && !loadError && apiKey) {
|
| 58 |
+
const timer = setTimeout(() => {
|
| 59 |
+
setLoadingTimeout(true)
|
| 60 |
+
}, 8000) // 8 second timeout
|
| 61 |
+
return () => clearTimeout(timer)
|
| 62 |
+
} else {
|
| 63 |
+
setLoadingTimeout(false)
|
| 64 |
+
}
|
| 65 |
+
}, [isLoaded, loadError, apiKey])
|
| 66 |
+
|
| 67 |
+
const handleMapClick = useCallback(
|
| 68 |
+
(e: google.maps.MapMouseEvent) => {
|
| 69 |
+
if (e.latLng && onMapClick) {
|
| 70 |
+
onMapClick(e.latLng.lat(), e.latLng.lng())
|
| 71 |
+
}
|
| 72 |
+
},
|
| 73 |
+
[onMapClick]
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
const handleMapLoad = useCallback(
|
| 77 |
+
(map: google.maps.Map) => {
|
| 78 |
+
onMapReady?.(map)
|
| 79 |
+
},
|
| 80 |
+
[onMapReady]
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
const getSeverityColor = (severity: string) => {
|
| 84 |
+
switch (severity) {
|
| 85 |
+
case 'high':
|
| 86 |
+
return '#ef4444'
|
| 87 |
+
case 'medium':
|
| 88 |
+
return '#f97316'
|
| 89 |
+
case 'low':
|
| 90 |
+
return '#eab308'
|
| 91 |
+
default:
|
| 92 |
+
return '#64748b'
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// Create marker icons - only create when map is loaded
|
| 97 |
+
const createMarkerIcon = useCallback((color: string) => {
|
| 98 |
+
if (!isLoaded || typeof google === 'undefined' || !google.maps) {
|
| 99 |
+
return undefined
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// Create a canvas-based circle icon - most reliable method
|
| 103 |
+
const canvas = document.createElement('canvas')
|
| 104 |
+
canvas.width = 24
|
| 105 |
+
canvas.height = 24
|
| 106 |
+
const ctx = canvas.getContext('2d')
|
| 107 |
+
if (!ctx) return undefined
|
| 108 |
+
|
| 109 |
+
// Draw filled circle
|
| 110 |
+
ctx.beginPath()
|
| 111 |
+
ctx.arc(12, 12, 10, 0, 2 * Math.PI)
|
| 112 |
+
ctx.fillStyle = color
|
| 113 |
+
ctx.fill()
|
| 114 |
+
|
| 115 |
+
// Draw white border
|
| 116 |
+
ctx.strokeStyle = '#ffffff'
|
| 117 |
+
ctx.lineWidth = 3
|
| 118 |
+
ctx.stroke()
|
| 119 |
+
|
| 120 |
+
const dataUrl = canvas.toDataURL()
|
| 121 |
+
|
| 122 |
+
return {
|
| 123 |
+
url: dataUrl,
|
| 124 |
+
scaledSize: new google.maps.Size(24, 24),
|
| 125 |
+
anchor: new google.maps.Point(12, 12),
|
| 126 |
+
}
|
| 127 |
+
}, [isLoaded])
|
| 128 |
+
|
| 129 |
+
if (loadError || loadingTimeout) {
|
| 130 |
+
return (
|
| 131 |
+
<div className="flex h-full min-h-[280px] w-full items-center justify-center rounded-xl border-2 border-slate-200 bg-slate-50 sm:min-h-[400px]">
|
| 132 |
+
<div className="text-center p-6">
|
| 133 |
+
<MapPin className="w-12 h-12 text-slate-400 mx-auto mb-4" />
|
| 134 |
+
<p className="text-slate-700 font-semibold mb-2 text-lg">Map Unavailable</p>
|
| 135 |
+
<p className="text-slate-500 text-sm mb-1">
|
| 136 |
+
{apiKey
|
| 137 |
+
? 'Google Maps API key may be invalid or restricted'
|
| 138 |
+
: 'Google Maps API key not configured'}
|
| 139 |
+
</p>
|
| 140 |
+
<p className="text-slate-400 text-xs mt-4">Outbreak reporting will work once the map loads</p>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
)
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
if (!isLoaded) {
|
| 147 |
+
return (
|
| 148 |
+
<div className="flex h-full min-h-[280px] w-full items-center justify-center rounded-xl border-2 border-blue-200 bg-blue-50 sm:min-h-[400px]">
|
| 149 |
+
<div className="text-center">
|
| 150 |
+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
| 151 |
+
<p className="text-blue-600 font-semibold">Loading Google Maps...</p>
|
| 152 |
+
{!apiKey && (
|
| 153 |
+
<p className="text-blue-500 text-xs mt-2">No API key configured</p>
|
| 154 |
+
)}
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
)
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
return (
|
| 161 |
+
<div className="relative h-full min-h-0 w-full overflow-hidden rounded-xl">
|
| 162 |
+
<style dangerouslySetInnerHTML={{
|
| 163 |
+
__html: `
|
| 164 |
+
/* Hide Google Maps watermark and "For development purposes only" text */
|
| 165 |
+
.gm-style-cc,
|
| 166 |
+
.gm-style-cc div,
|
| 167 |
+
.gm-style-cc a,
|
| 168 |
+
a[href^="https://developers.google.com/maps"],
|
| 169 |
+
.gm-bundled-control .gm-style-cc,
|
| 170 |
+
div[title*="For development purposes only"],
|
| 171 |
+
div[aria-label*="For development purposes only"] {
|
| 172 |
+
display: none !important;
|
| 173 |
+
visibility: hidden !important;
|
| 174 |
+
opacity: 0 !important;
|
| 175 |
+
height: 0 !important;
|
| 176 |
+
width: 0 !important;
|
| 177 |
+
overflow: hidden !important;
|
| 178 |
+
}
|
| 179 |
+
/* Hide default POI marker images only (avoid div[style*="place"] etc. — it breaks map controls) */
|
| 180 |
+
.gm-style img[src*="poi"],
|
| 181 |
+
.gm-style img[src*="place"],
|
| 182 |
+
.gm-style img[src*="marker"][src*="default"] {
|
| 183 |
+
display: none !important;
|
| 184 |
+
visibility: hidden !important;
|
| 185 |
+
opacity: 0 !important;
|
| 186 |
+
height: 0 !important;
|
| 187 |
+
width: 0 !important;
|
| 188 |
+
pointer-events: none !important;
|
| 189 |
+
}
|
| 190 |
+
`
|
| 191 |
+
}} />
|
| 192 |
+
<GoogleMap
|
| 193 |
+
mapContainerStyle={mapContainerStyle}
|
| 194 |
+
center={mapCenter}
|
| 195 |
+
zoom={zoom}
|
| 196 |
+
onLoad={handleMapLoad}
|
| 197 |
+
onClick={handleMapClick}
|
| 198 |
+
options={{
|
| 199 |
+
disableDefaultUI: false,
|
| 200 |
+
zoomControl: true,
|
| 201 |
+
streetViewControl: false,
|
| 202 |
+
mapTypeControl: true,
|
| 203 |
+
fullscreenControl,
|
| 204 |
+
gestureHandling: 'greedy',
|
| 205 |
+
clickableIcons: false, // Disable clickable POI icons
|
| 206 |
+
restriction: {
|
| 207 |
+
latLngBounds: {
|
| 208 |
+
north: 49.38,
|
| 209 |
+
south: 24.40,
|
| 210 |
+
west: -125.0,
|
| 211 |
+
east: -66.93,
|
| 212 |
+
},
|
| 213 |
+
strictBounds: false,
|
| 214 |
+
},
|
| 215 |
+
styles: [
|
| 216 |
+
{
|
| 217 |
+
featureType: 'poi',
|
| 218 |
+
elementType: 'all',
|
| 219 |
+
stylers: [{ visibility: 'off' }],
|
| 220 |
+
},
|
| 221 |
+
{
|
| 222 |
+
featureType: 'poi',
|
| 223 |
+
elementType: 'labels',
|
| 224 |
+
stylers: [{ visibility: 'off' }],
|
| 225 |
+
},
|
| 226 |
+
{
|
| 227 |
+
featureType: 'poi',
|
| 228 |
+
elementType: 'geometry',
|
| 229 |
+
stylers: [{ visibility: 'off' }],
|
| 230 |
+
},
|
| 231 |
+
{
|
| 232 |
+
featureType: 'poi.business',
|
| 233 |
+
elementType: 'all',
|
| 234 |
+
stylers: [{ visibility: 'off' }],
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
featureType: 'poi.attraction',
|
| 238 |
+
elementType: 'all',
|
| 239 |
+
stylers: [{ visibility: 'off' }],
|
| 240 |
+
},
|
| 241 |
+
{
|
| 242 |
+
featureType: 'poi.place_of_worship',
|
| 243 |
+
elementType: 'all',
|
| 244 |
+
stylers: [{ visibility: 'off' }],
|
| 245 |
+
},
|
| 246 |
+
{
|
| 247 |
+
featureType: 'poi.school',
|
| 248 |
+
elementType: 'all',
|
| 249 |
+
stylers: [{ visibility: 'off' }],
|
| 250 |
+
},
|
| 251 |
+
{
|
| 252 |
+
featureType: 'poi.sports_complex',
|
| 253 |
+
elementType: 'all',
|
| 254 |
+
stylers: [{ visibility: 'off' }],
|
| 255 |
+
},
|
| 256 |
+
],
|
| 257 |
+
}}
|
| 258 |
+
>
|
| 259 |
+
{reports.map((report) => {
|
| 260 |
+
const color = getSeverityColor(report.severity)
|
| 261 |
+
|
| 262 |
+
// Create icon directly here to ensure it's created when map is ready
|
| 263 |
+
if (!isLoaded || typeof google === 'undefined' || !google.maps) {
|
| 264 |
+
return null
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
// Create canvas icon on the fly
|
| 268 |
+
const canvas = document.createElement('canvas')
|
| 269 |
+
canvas.width = 24
|
| 270 |
+
canvas.height = 24
|
| 271 |
+
const ctx = canvas.getContext('2d')
|
| 272 |
+
if (!ctx) return null
|
| 273 |
+
|
| 274 |
+
// Draw colored circle
|
| 275 |
+
ctx.beginPath()
|
| 276 |
+
ctx.arc(12, 12, 10, 0, 2 * Math.PI)
|
| 277 |
+
ctx.fillStyle = color
|
| 278 |
+
ctx.fill()
|
| 279 |
+
ctx.strokeStyle = '#ffffff'
|
| 280 |
+
ctx.lineWidth = 3
|
| 281 |
+
ctx.stroke()
|
| 282 |
+
|
| 283 |
+
const iconUrl = canvas.toDataURL()
|
| 284 |
+
|
| 285 |
+
return (
|
| 286 |
+
<Marker
|
| 287 |
+
key={report.id}
|
| 288 |
+
position={{ lat: report.lat, lng: report.lng }}
|
| 289 |
+
icon={{
|
| 290 |
+
url: iconUrl,
|
| 291 |
+
scaledSize: new google.maps.Size(24, 24),
|
| 292 |
+
anchor: new google.maps.Point(12, 12),
|
| 293 |
+
}}
|
| 294 |
+
clickable={true}
|
| 295 |
+
cursor="pointer"
|
| 296 |
+
title={`${report.crop} - ${report.disease} (${report.severity} severity)`}
|
| 297 |
+
onClick={() => {
|
| 298 |
+
setSelectedReport(report)
|
| 299 |
+
}}
|
| 300 |
+
animation={google.maps.Animation.DROP}
|
| 301 |
+
/>
|
| 302 |
+
)
|
| 303 |
+
})}
|
| 304 |
+
|
| 305 |
+
{selectedReport && (
|
| 306 |
+
<InfoWindow
|
| 307 |
+
position={{ lat: selectedReport.lat, lng: selectedReport.lng }}
|
| 308 |
+
onCloseClick={() => setSelectedReport(null)}
|
| 309 |
+
options={{
|
| 310 |
+
pixelOffset: new google.maps.Size(0, -10),
|
| 311 |
+
}}
|
| 312 |
+
>
|
| 313 |
+
<div className="p-3 min-w-[min(18rem,calc(100vw-3rem))] max-w-[min(300px,calc(100vw-2rem))]">
|
| 314 |
+
<div className="flex items-start justify-between mb-2">
|
| 315 |
+
<h3 className="font-bold text-lg text-gray-900">
|
| 316 |
+
{selectedReport.crop.charAt(0).toUpperCase() + selectedReport.crop.slice(1)} - {selectedReport.disease}
|
| 317 |
+
</h3>
|
| 318 |
+
<button
|
| 319 |
+
onClick={() => setSelectedReport(null)}
|
| 320 |
+
className="text-gray-400 hover:text-gray-600 ml-2"
|
| 321 |
+
aria-label="Close"
|
| 322 |
+
>
|
| 323 |
+
×
|
| 324 |
+
</button>
|
| 325 |
+
</div>
|
| 326 |
+
|
| 327 |
+
<div className="mb-3">
|
| 328 |
+
<span
|
| 329 |
+
className={`inline-block px-3 py-1 rounded-full text-xs font-bold ${
|
| 330 |
+
selectedReport.severity === 'high'
|
| 331 |
+
? 'bg-red-100 text-red-800 border-2 border-red-300'
|
| 332 |
+
: selectedReport.severity === 'medium'
|
| 333 |
+
? 'bg-orange-100 text-orange-800 border-2 border-orange-300'
|
| 334 |
+
: 'bg-yellow-100 text-yellow-800 border-2 border-yellow-300'
|
| 335 |
+
}`}
|
| 336 |
+
>
|
| 337 |
+
{selectedReport.severity.toUpperCase()} SEVERITY
|
| 338 |
+
</span>
|
| 339 |
+
</div>
|
| 340 |
+
|
| 341 |
+
{selectedReport.description && (
|
| 342 |
+
<p className="text-sm text-gray-700 mb-3 leading-relaxed">
|
| 343 |
+
{selectedReport.description}
|
| 344 |
+
</p>
|
| 345 |
+
)}
|
| 346 |
+
|
| 347 |
+
{selectedReport.reporterVerified !== undefined && (
|
| 348 |
+
<div className="mb-3">
|
| 349 |
+
<span
|
| 350 |
+
className={`inline-block text-xs font-bold px-2.5 py-1 rounded-full border ${
|
| 351 |
+
selectedReport.reporterVerified
|
| 352 |
+
? 'bg-emerald-100 text-emerald-900 border-emerald-300'
|
| 353 |
+
: 'bg-slate-100 text-slate-700 border-slate-300'
|
| 354 |
+
}`}
|
| 355 |
+
>
|
| 356 |
+
{selectedReport.reporterVerified ? 'Verified farmer report' : 'Unverified farmer report'}
|
| 357 |
+
</span>
|
| 358 |
+
</div>
|
| 359 |
+
)}
|
| 360 |
+
|
| 361 |
+
<div className="border-t border-gray-200 pt-2 mt-2">
|
| 362 |
+
<p className="text-xs text-gray-500">
|
| 363 |
+
<span className="font-semibold">Location:</span> {selectedReport.lat.toFixed(4)}, {selectedReport.lng.toFixed(4)}
|
| 364 |
+
</p>
|
| 365 |
+
<p className="text-xs text-gray-500 mt-1">
|
| 366 |
+
<span className="font-semibold">Reported:</span> {new Date(selectedReport.date).toLocaleString()}
|
| 367 |
+
</p>
|
| 368 |
+
</div>
|
| 369 |
+
</div>
|
| 370 |
+
</InfoWindow>
|
| 371 |
+
)}
|
| 372 |
+
</GoogleMap>
|
| 373 |
+
|
| 374 |
+
{/* Click instruction overlay */}
|
| 375 |
+
{showMapClickHint && (
|
| 376 |
+
<div className="absolute bottom-3 left-3 right-3 sm:left-6 sm:right-auto sm:bottom-6 bg-gradient-to-r from-blue-600 to-blue-700 text-white px-3 py-2.5 sm:px-5 sm:py-3 rounded-lg shadow-xl z-10 border border-blue-800 pointer-events-none">
|
| 377 |
+
<p className="text-xs sm:text-sm font-semibold flex items-center gap-2">
|
| 378 |
+
<MapPin className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" />
|
| 379 |
+
Tap or click the map to report an outbreak
|
| 380 |
+
</p>
|
| 381 |
+
</div>
|
| 382 |
+
)}
|
| 383 |
+
</div>
|
| 384 |
+
)
|
| 385 |
+
}
|
components/HealthComparisonPanel.tsx
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useState } from 'react'
|
| 4 |
+
import { ArrowLeftRight, Loader2, TrendingDown, TrendingUp, Minus } from 'lucide-react'
|
| 5 |
+
import ImageUpload from '@/components/ImageUpload'
|
| 6 |
+
import { compareHealthTrend, trendLabel, type HealthTrend } from '@/lib/healthComparison'
|
| 7 |
+
import type { PredictionPayload } from '@/lib/stateDiseaseMap'
|
| 8 |
+
|
| 9 |
+
type Props = {
|
| 10 |
+
crop: string
|
| 11 |
+
applyRegionalFilter: (raw: PredictionPayload) => PredictionPayload
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
async function runPredict(file: File, crop: string): Promise<PredictionPayload> {
|
| 15 |
+
const formData = new FormData()
|
| 16 |
+
formData.append('image', file)
|
| 17 |
+
formData.append('crop', crop)
|
| 18 |
+
const response = await fetch('/api/predict', { method: 'POST', body: formData })
|
| 19 |
+
if (!response.ok) {
|
| 20 |
+
const err = await response.json().catch(() => ({}))
|
| 21 |
+
throw new Error(err.error || 'Prediction failed')
|
| 22 |
+
}
|
| 23 |
+
const data = await response.json()
|
| 24 |
+
return {
|
| 25 |
+
disease: data.disease,
|
| 26 |
+
confidence: data.confidence,
|
| 27 |
+
is_healthy: data.is_healthy,
|
| 28 |
+
meets_threshold: data.meets_threshold,
|
| 29 |
+
all_predictions: data.all_predictions,
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function trendStyles(t: HealthTrend) {
|
| 34 |
+
switch (t) {
|
| 35 |
+
case 'improving':
|
| 36 |
+
return {
|
| 37 |
+
border: 'border-emerald-300',
|
| 38 |
+
bg: 'bg-emerald-50',
|
| 39 |
+
text: 'text-emerald-900',
|
| 40 |
+
Icon: TrendingUp,
|
| 41 |
+
}
|
| 42 |
+
case 'worsening':
|
| 43 |
+
return {
|
| 44 |
+
border: 'border-rose-300',
|
| 45 |
+
bg: 'bg-rose-50',
|
| 46 |
+
text: 'text-rose-900',
|
| 47 |
+
Icon: TrendingDown,
|
| 48 |
+
}
|
| 49 |
+
default:
|
| 50 |
+
return {
|
| 51 |
+
border: 'border-slate-300',
|
| 52 |
+
bg: 'bg-slate-50',
|
| 53 |
+
text: 'text-slate-800',
|
| 54 |
+
Icon: Minus,
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export default function HealthComparisonPanel({ crop, applyRegionalFilter }: Props) {
|
| 60 |
+
const [pastFile, setPastFile] = useState<File | null>(null)
|
| 61 |
+
const [currentFile, setCurrentFile] = useState<File | null>(null)
|
| 62 |
+
const [pastUrl, setPastUrl] = useState<string | null>(null)
|
| 63 |
+
const [currentUrl, setCurrentUrl] = useState<string | null>(null)
|
| 64 |
+
const [pastPred, setPastPred] = useState<PredictionPayload | null>(null)
|
| 65 |
+
const [currentPred, setCurrentPred] = useState<PredictionPayload | null>(null)
|
| 66 |
+
const [loading, setLoading] = useState(false)
|
| 67 |
+
const [error, setError] = useState<string | null>(null)
|
| 68 |
+
|
| 69 |
+
const onPastSelect = (file: File | null) => {
|
| 70 |
+
setPastFile(file)
|
| 71 |
+
setPastUrl((prev) => {
|
| 72 |
+
if (prev) URL.revokeObjectURL(prev)
|
| 73 |
+
return file ? URL.createObjectURL(file) : null
|
| 74 |
+
})
|
| 75 |
+
setPastPred(null)
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const onCurrentSelect = (file: File | null) => {
|
| 79 |
+
setCurrentFile(file)
|
| 80 |
+
setCurrentUrl((prev) => {
|
| 81 |
+
if (prev) URL.revokeObjectURL(prev)
|
| 82 |
+
return file ? URL.createObjectURL(file) : null
|
| 83 |
+
})
|
| 84 |
+
setCurrentPred(null)
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
const clearPast = () => onPastSelect(null)
|
| 88 |
+
const clearCurrent = () => onCurrentSelect(null)
|
| 89 |
+
|
| 90 |
+
const handleCompare = async () => {
|
| 91 |
+
if (!pastFile || !currentFile) {
|
| 92 |
+
setError('Please upload both a past and a current photo.')
|
| 93 |
+
return
|
| 94 |
+
}
|
| 95 |
+
setLoading(true)
|
| 96 |
+
setError(null)
|
| 97 |
+
setPastPred(null)
|
| 98 |
+
setCurrentPred(null)
|
| 99 |
+
try {
|
| 100 |
+
const [rawPast, rawCurrent] = await Promise.all([
|
| 101 |
+
runPredict(pastFile, crop),
|
| 102 |
+
runPredict(currentFile, crop),
|
| 103 |
+
])
|
| 104 |
+
setPastPred(applyRegionalFilter(rawPast))
|
| 105 |
+
setCurrentPred(applyRegionalFilter(rawCurrent))
|
| 106 |
+
} catch (e: unknown) {
|
| 107 |
+
setError(e instanceof Error ? e.message : 'Comparison failed')
|
| 108 |
+
} finally {
|
| 109 |
+
setLoading(false)
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
const trend =
|
| 114 |
+
pastPred && currentPred
|
| 115 |
+
? compareHealthTrend(
|
| 116 |
+
{ disease: pastPred.disease, crop, is_healthy: pastPred.is_healthy },
|
| 117 |
+
{ disease: currentPred.disease, crop, is_healthy: currentPred.is_healthy }
|
| 118 |
+
)
|
| 119 |
+
: null
|
| 120 |
+
|
| 121 |
+
const ts = trend ? trendStyles(trend) : null
|
| 122 |
+
|
| 123 |
+
return (
|
| 124 |
+
<div className="mt-8 pt-8 border-t border-slate-200">
|
| 125 |
+
<h3 className="text-base font-semibold text-slate-900 flex items-center gap-2">
|
| 126 |
+
<ArrowLeftRight className="w-5 h-5 text-primary-700" />
|
| 127 |
+
Compare past vs current
|
| 128 |
+
</h3>
|
| 129 |
+
<p className="text-sm text-slate-600 mt-1 mb-5">
|
| 130 |
+
Upload an older leaf photo and a current one (same crop). We run both through the same model and summarize the
|
| 131 |
+
trend.
|
| 132 |
+
</p>
|
| 133 |
+
|
| 134 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 135 |
+
<div>
|
| 136 |
+
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Past</p>
|
| 137 |
+
<ImageUpload
|
| 138 |
+
selectedImage={pastFile}
|
| 139 |
+
onImageSelect={onPastSelect}
|
| 140 |
+
onClear={clearPast}
|
| 141 |
+
title="Past photo"
|
| 142 |
+
hint="Older image for comparison."
|
| 143 |
+
/>
|
| 144 |
+
</div>
|
| 145 |
+
<div>
|
| 146 |
+
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Current</p>
|
| 147 |
+
<ImageUpload
|
| 148 |
+
selectedImage={currentFile}
|
| 149 |
+
onImageSelect={onCurrentSelect}
|
| 150 |
+
onClear={clearCurrent}
|
| 151 |
+
title="Current photo"
|
| 152 |
+
hint="Most recent image."
|
| 153 |
+
/>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
<button
|
| 158 |
+
type="button"
|
| 159 |
+
onClick={handleCompare}
|
| 160 |
+
disabled={!pastFile || !currentFile || loading}
|
| 161 |
+
className="touch-manipulation mt-6 min-h-[48px] w-full md:w-auto inline-flex items-center justify-center gap-2 px-6 py-3 rounded-xl bg-slate-900 text-white font-semibold shadow-sm hover:bg-slate-800 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors"
|
| 162 |
+
>
|
| 163 |
+
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowLeftRight className="w-4 h-4" />}
|
| 164 |
+
{loading ? 'Analyzing both…' : 'Compare photos'}
|
| 165 |
+
</button>
|
| 166 |
+
|
| 167 |
+
{error && (
|
| 168 |
+
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-900">{error}</div>
|
| 169 |
+
)}
|
| 170 |
+
|
| 171 |
+
{pastPred && currentPred && trend && ts && (
|
| 172 |
+
<div className="mt-8 space-y-6">
|
| 173 |
+
<div
|
| 174 |
+
className={`flex flex-col sm:flex-row items-center justify-center gap-4 rounded-2xl border-2 px-6 py-5 ${ts.border} ${ts.bg}`}
|
| 175 |
+
>
|
| 176 |
+
<ts.Icon className={`w-10 h-10 shrink-0 ${ts.text}`} />
|
| 177 |
+
<div className="text-center sm:text-left">
|
| 178 |
+
<p className={`text-xs font-semibold uppercase tracking-wide ${ts.text} opacity-80`}>Comparison</p>
|
| 179 |
+
<p className={`text-2xl font-bold ${ts.text}`}>{trendLabel(trend)}</p>
|
| 180 |
+
<p className="text-sm text-slate-600 mt-1 max-w-md">
|
| 181 |
+
Based on estimated severity from each diagnosis (not a substitute for scouting or lab tests).
|
| 182 |
+
</p>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 187 |
+
<ComparisonCard label="Past" imageUrl={pastUrl} prediction={pastPred} accent="border-slate-200" />
|
| 188 |
+
<ComparisonCard label="Current" imageUrl={currentUrl} prediction={currentPred} accent="border-primary-200" />
|
| 189 |
+
</div>
|
| 190 |
+
</div>
|
| 191 |
+
)}
|
| 192 |
+
</div>
|
| 193 |
+
)
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
function ComparisonCard({
|
| 197 |
+
label,
|
| 198 |
+
imageUrl,
|
| 199 |
+
prediction,
|
| 200 |
+
accent,
|
| 201 |
+
}: {
|
| 202 |
+
label: string
|
| 203 |
+
imageUrl: string | null
|
| 204 |
+
prediction: PredictionPayload
|
| 205 |
+
accent: string
|
| 206 |
+
}) {
|
| 207 |
+
return (
|
| 208 |
+
<div className={`rounded-2xl border-2 bg-white overflow-hidden shadow-sm ${accent}`}>
|
| 209 |
+
<div className="px-4 py-2 bg-slate-50 border-b border-slate-200">
|
| 210 |
+
<span className="text-sm font-bold text-slate-800">{label}</span>
|
| 211 |
+
</div>
|
| 212 |
+
{imageUrl && (
|
| 213 |
+
<div className="h-44 w-full bg-slate-100 flex items-center justify-center p-2">
|
| 214 |
+
<img src={imageUrl} alt="" className="max-h-full max-w-full object-contain rounded-lg" />
|
| 215 |
+
</div>
|
| 216 |
+
)}
|
| 217 |
+
<div className="p-4">
|
| 218 |
+
<p className="text-xs text-slate-500 uppercase font-semibold">Prediction</p>
|
| 219 |
+
<p className="text-lg font-semibold text-slate-900 mt-0.5">{prediction.disease}</p>
|
| 220 |
+
<p className="text-sm text-primary-800 font-semibold mt-2 tabular-nums">
|
| 221 |
+
Confidence {typeof prediction.confidence === 'number' ? prediction.confidence.toFixed(1) : '—'}%
|
| 222 |
+
</p>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
)
|
| 226 |
+
}
|
components/ImageUpload.tsx
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useState, useRef } from 'react'
|
| 4 |
+
import { Image as ImageIcon, Upload, X } from 'lucide-react'
|
| 5 |
+
|
| 6 |
+
interface ImageUploadProps {
|
| 7 |
+
selectedImage: File | null
|
| 8 |
+
onImageSelect: (file: File | null) => void
|
| 9 |
+
onClear: () => void
|
| 10 |
+
/** Override default upload prompt (e.g. "Past photo") */
|
| 11 |
+
title?: string
|
| 12 |
+
hint?: string
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export default function ImageUpload({
|
| 16 |
+
selectedImage,
|
| 17 |
+
onImageSelect,
|
| 18 |
+
onClear,
|
| 19 |
+
title = 'Upload a leaf photo',
|
| 20 |
+
hint = 'Drag & drop, choose a file, or take a photo.',
|
| 21 |
+
}: ImageUploadProps) {
|
| 22 |
+
const [dragActive, setDragActive] = useState(false)
|
| 23 |
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
| 24 |
+
const cameraInputRef = useRef<HTMLInputElement>(null)
|
| 25 |
+
|
| 26 |
+
const handleDrag = (e: React.DragEvent) => {
|
| 27 |
+
e.preventDefault()
|
| 28 |
+
e.stopPropagation()
|
| 29 |
+
if (e.type === 'dragenter' || e.type === 'dragover') {
|
| 30 |
+
setDragActive(true)
|
| 31 |
+
} else if (e.type === 'dragleave') {
|
| 32 |
+
setDragActive(false)
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const handleDrop = (e: React.DragEvent) => {
|
| 37 |
+
e.preventDefault()
|
| 38 |
+
e.stopPropagation()
|
| 39 |
+
setDragActive(false)
|
| 40 |
+
|
| 41 |
+
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
| 42 |
+
handleFile(e.dataTransfer.files[0])
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 47 |
+
if (e.target.files && e.target.files[0]) {
|
| 48 |
+
handleFile(e.target.files[0])
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
const handleFile = (file: File) => {
|
| 53 |
+
if (file.type.startsWith('image/')) {
|
| 54 |
+
onImageSelect(file)
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const imageUrl = selectedImage ? URL.createObjectURL(selectedImage) : null
|
| 59 |
+
|
| 60 |
+
return (
|
| 61 |
+
<div>
|
| 62 |
+
{!imageUrl ? (
|
| 63 |
+
<div
|
| 64 |
+
className={`border border-dashed rounded-2xl p-6 sm:p-10 text-center transition-all duration-200 bg-slate-50/60 ${
|
| 65 |
+
dragActive
|
| 66 |
+
? 'border-primary-500 bg-white shadow-md'
|
| 67 |
+
: 'border-slate-300 hover:border-slate-400 hover:bg-white'
|
| 68 |
+
}`}
|
| 69 |
+
onDragEnter={handleDrag}
|
| 70 |
+
onDragLeave={handleDrag}
|
| 71 |
+
onDragOver={handleDrag}
|
| 72 |
+
onDrop={handleDrop}
|
| 73 |
+
>
|
| 74 |
+
<div className="mx-auto w-12 h-12 rounded-2xl bg-white border border-slate-200 shadow-sm flex items-center justify-center mb-4">
|
| 75 |
+
<ImageIcon className="w-6 h-6 text-primary-700" />
|
| 76 |
+
</div>
|
| 77 |
+
<p className="text-base font-semibold text-slate-900 mb-1">{title}</p>
|
| 78 |
+
<p className="text-sm text-slate-600 mb-5">{hint}</p>
|
| 79 |
+
<div className="flex flex-col sm:flex-row gap-2 justify-center items-stretch sm:items-center max-w-md mx-auto w-full">
|
| 80 |
+
<button
|
| 81 |
+
type="button"
|
| 82 |
+
onClick={() => fileInputRef.current?.click()}
|
| 83 |
+
className="touch-manipulation inline-flex items-center justify-center gap-2 min-h-[44px] px-5 py-2.5 bg-slate-900 text-white rounded-xl font-semibold hover:bg-slate-800 transition-colors shadow-sm"
|
| 84 |
+
>
|
| 85 |
+
<Upload className="w-4 h-4 shrink-0" />
|
| 86 |
+
Choose file
|
| 87 |
+
</button>
|
| 88 |
+
<button
|
| 89 |
+
type="button"
|
| 90 |
+
onClick={() => cameraInputRef.current?.click()}
|
| 91 |
+
className="touch-manipulation inline-flex items-center justify-center gap-2 min-h-[44px] px-5 py-2.5 bg-white text-slate-900 border-2 border-slate-200 rounded-xl font-semibold hover:bg-slate-50 transition-colors shadow-sm"
|
| 92 |
+
>
|
| 93 |
+
<ImageIcon className="w-4 h-4 shrink-0" />
|
| 94 |
+
Take photo
|
| 95 |
+
</button>
|
| 96 |
+
</div>
|
| 97 |
+
<input
|
| 98 |
+
ref={fileInputRef}
|
| 99 |
+
type="file"
|
| 100 |
+
accept="image/*"
|
| 101 |
+
onChange={handleFileInput}
|
| 102 |
+
className="hidden"
|
| 103 |
+
/>
|
| 104 |
+
<input
|
| 105 |
+
ref={cameraInputRef}
|
| 106 |
+
type="file"
|
| 107 |
+
accept="image/*"
|
| 108 |
+
capture="environment"
|
| 109 |
+
onChange={handleFileInput}
|
| 110 |
+
className="hidden"
|
| 111 |
+
/>
|
| 112 |
+
</div>
|
| 113 |
+
) : (
|
| 114 |
+
<div className="relative group bg-white rounded-2xl p-4 border border-slate-200 shadow-sm">
|
| 115 |
+
<div className="relative w-full h-auto max-h-[520px] flex items-center justify-center bg-slate-50 rounded-xl overflow-hidden border border-slate-200">
|
| 116 |
+
<img
|
| 117 |
+
src={imageUrl}
|
| 118 |
+
alt="Crop image preview"
|
| 119 |
+
className="w-full h-auto max-h-[500px] object-contain rounded-lg"
|
| 120 |
+
style={{ display: 'block', maxWidth: '100%', height: 'auto' }}
|
| 121 |
+
onError={(e) => {
|
| 122 |
+
console.error('Image failed to load:', imageUrl)
|
| 123 |
+
e.currentTarget.style.display = 'none'
|
| 124 |
+
}}
|
| 125 |
+
/>
|
| 126 |
+
</div>
|
| 127 |
+
<button
|
| 128 |
+
type="button"
|
| 129 |
+
onClick={onClear}
|
| 130 |
+
className="touch-manipulation absolute top-3 right-3 sm:top-6 sm:right-6 min-h-[44px] min-w-[44px] px-3 py-2 bg-white/90 hover:bg-white text-slate-900 rounded-xl transition-colors shadow-sm border border-slate-200 flex items-center justify-center gap-2 font-semibold text-sm"
|
| 131 |
+
>
|
| 132 |
+
<X className="w-4 h-4" />
|
| 133 |
+
Remove
|
| 134 |
+
</button>
|
| 135 |
+
</div>
|
| 136 |
+
)}
|
| 137 |
+
</div>
|
| 138 |
+
)
|
| 139 |
+
}
|
components/LeafletMap.tsx
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from 'react'
|
| 4 |
+
import { MapContainer, TileLayer, Marker, Popup, useMapEvents } from 'react-leaflet'
|
| 5 |
+
import L from 'leaflet'
|
| 6 |
+
import 'leaflet/dist/leaflet.css'
|
| 7 |
+
|
| 8 |
+
// Fix for default marker icons in Next.js
|
| 9 |
+
if (typeof window !== 'undefined') {
|
| 10 |
+
delete (L.Icon.Default.prototype as any)._getIconUrl
|
| 11 |
+
L.Icon.Default.mergeOptions({
|
| 12 |
+
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
|
| 13 |
+
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
|
| 14 |
+
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
|
| 15 |
+
})
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
interface OutbreakReport {
|
| 19 |
+
id: string
|
| 20 |
+
lat: number
|
| 21 |
+
lng: number
|
| 22 |
+
crop: string
|
| 23 |
+
disease: string
|
| 24 |
+
severity: 'low' | 'medium' | 'high'
|
| 25 |
+
date: string
|
| 26 |
+
description: string
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
interface LeafletMapProps {
|
| 30 |
+
reports?: OutbreakReport[]
|
| 31 |
+
onMapClick?: (lat: number, lng: number) => void
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Component to handle map clicks
|
| 35 |
+
function MapClickHandler({ onClick }: { onClick: (lat: number, lng: number) => void }) {
|
| 36 |
+
useMapEvents({
|
| 37 |
+
click: (e) => {
|
| 38 |
+
if (onClick) {
|
| 39 |
+
onClick(e.latlng.lat, e.latlng.lng)
|
| 40 |
+
}
|
| 41 |
+
},
|
| 42 |
+
})
|
| 43 |
+
return null
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export default function LeafletMap({ reports = [], onMapClick }: LeafletMapProps) {
|
| 47 |
+
useEffect(() => {
|
| 48 |
+
// Ensure we're in the browser
|
| 49 |
+
if (typeof window === 'undefined') return
|
| 50 |
+
}, [])
|
| 51 |
+
|
| 52 |
+
const getSeverityColor = (severity: string) => {
|
| 53 |
+
switch (severity) {
|
| 54 |
+
case 'high':
|
| 55 |
+
return '#ef4444'
|
| 56 |
+
case 'medium':
|
| 57 |
+
return '#f97316'
|
| 58 |
+
case 'low':
|
| 59 |
+
return '#eab308'
|
| 60 |
+
default:
|
| 61 |
+
return '#64748b'
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
const createMarkerIcon = (severity: string) => {
|
| 66 |
+
const color = getSeverityColor(severity)
|
| 67 |
+
return L.divIcon({
|
| 68 |
+
className: 'custom-marker',
|
| 69 |
+
html: `<div style="
|
| 70 |
+
width: 20px;
|
| 71 |
+
height: 20px;
|
| 72 |
+
background-color: ${color};
|
| 73 |
+
border: 3px solid white;
|
| 74 |
+
border-radius: 50%;
|
| 75 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
| 76 |
+
"></div>`,
|
| 77 |
+
iconSize: [20, 20],
|
| 78 |
+
iconAnchor: [10, 10],
|
| 79 |
+
})
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
return (
|
| 83 |
+
<MapContainer
|
| 84 |
+
center={[39.8283, -98.5795]} // Center of US
|
| 85 |
+
zoom={4}
|
| 86 |
+
style={{ height: '100%', width: '100%', zIndex: 0 }}
|
| 87 |
+
maxBounds={[
|
| 88 |
+
[24.39, -125], // Southwest corner
|
| 89 |
+
[49.38, -66.93], // Northeast corner
|
| 90 |
+
]}
|
| 91 |
+
minZoom={4}
|
| 92 |
+
maxZoom={10}
|
| 93 |
+
>
|
| 94 |
+
<TileLayer
|
| 95 |
+
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
| 96 |
+
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
| 97 |
+
/>
|
| 98 |
+
|
| 99 |
+
<MapClickHandler onClick={onMapClick || (() => {})} />
|
| 100 |
+
|
| 101 |
+
{/* Markers for existing reports */}
|
| 102 |
+
{reports.map((report) => (
|
| 103 |
+
<Marker
|
| 104 |
+
key={report.id}
|
| 105 |
+
position={[report.lat, report.lng]}
|
| 106 |
+
icon={createMarkerIcon(report.severity)}
|
| 107 |
+
>
|
| 108 |
+
<Popup>
|
| 109 |
+
<div className="p-2">
|
| 110 |
+
<h3 className="font-bold text-lg mb-1">
|
| 111 |
+
{report.crop} - {report.disease}
|
| 112 |
+
</h3>
|
| 113 |
+
<p className="text-sm text-gray-600 mb-1">
|
| 114 |
+
<span
|
| 115 |
+
className={`px-2 py-1 rounded text-xs font-bold ${
|
| 116 |
+
report.severity === 'high'
|
| 117 |
+
? 'bg-red-100 text-red-800'
|
| 118 |
+
: report.severity === 'medium'
|
| 119 |
+
? 'bg-orange-100 text-orange-800'
|
| 120 |
+
: 'bg-yellow-100 text-yellow-800'
|
| 121 |
+
}`}
|
| 122 |
+
>
|
| 123 |
+
{report.severity.toUpperCase()}
|
| 124 |
+
</span>
|
| 125 |
+
</p>
|
| 126 |
+
{report.description && (
|
| 127 |
+
<p className="text-sm text-gray-700 mt-2">{report.description}</p>
|
| 128 |
+
)}
|
| 129 |
+
<p className="text-xs text-gray-500 mt-2">
|
| 130 |
+
{new Date(report.date).toLocaleString()}
|
| 131 |
+
</p>
|
| 132 |
+
</div>
|
| 133 |
+
</Popup>
|
| 134 |
+
</Marker>
|
| 135 |
+
))}
|
| 136 |
+
</MapContainer>
|
| 137 |
+
)
|
| 138 |
+
}
|
components/NotificationSystem.tsx
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useMemo, useRef } from 'react'
|
| 4 |
+
import { Bell, X, AlertTriangle, MapPin, Check } from 'lucide-react'
|
| 5 |
+
import {
|
| 6 |
+
findAffectedFarmers,
|
| 7 |
+
generateNotificationMessage,
|
| 8 |
+
type OutbreakLocation,
|
| 9 |
+
type FarmerLocation,
|
| 10 |
+
type Notification,
|
| 11 |
+
} from '@/lib/notifications'
|
| 12 |
+
import type { OutbreakReport } from '@/lib/outbreakReport'
|
| 13 |
+
|
| 14 |
+
interface NotificationSystemProps {
|
| 15 |
+
outbreaks: OutbreakReport[]
|
| 16 |
+
currentFarmerLocation?: { lat: number; lng: number; crops: string[] }
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const SEVERITY_SORT = { high: 0, medium: 1, low: 2 } as const
|
| 20 |
+
|
| 21 |
+
export default function NotificationSystem({
|
| 22 |
+
outbreaks,
|
| 23 |
+
currentFarmerLocation,
|
| 24 |
+
}: NotificationSystemProps) {
|
| 25 |
+
const [notifications, setNotifications] = useState<Notification[]>([])
|
| 26 |
+
const [isOpen, setIsOpen] = useState(false)
|
| 27 |
+
/** Outbreak IDs the user dismissed — otherwise the sync effect recreates them */
|
| 28 |
+
const [dismissedOutbreakIds, setDismissedOutbreakIds] = useState<Set<string>>(() => new Set())
|
| 29 |
+
const rootRef = useRef<HTMLDivElement>(null)
|
| 30 |
+
|
| 31 |
+
useEffect(() => {
|
| 32 |
+
if (!isOpen) return
|
| 33 |
+
const onKeyDown = (e: KeyboardEvent) => {
|
| 34 |
+
if (e.key === 'Escape') setIsOpen(false)
|
| 35 |
+
}
|
| 36 |
+
// Bubble-phase click avoids capture-phase pointer handlers stealing taps before button onClick runs.
|
| 37 |
+
const onDocumentClick = (e: MouseEvent) => {
|
| 38 |
+
const root = rootRef.current
|
| 39 |
+
if (root && !root.contains(e.target as Node)) setIsOpen(false)
|
| 40 |
+
}
|
| 41 |
+
window.addEventListener('keydown', onKeyDown)
|
| 42 |
+
document.addEventListener('click', onDocumentClick)
|
| 43 |
+
return () => {
|
| 44 |
+
window.removeEventListener('keydown', onKeyDown)
|
| 45 |
+
document.removeEventListener('click', onDocumentClick)
|
| 46 |
+
}
|
| 47 |
+
}, [isOpen])
|
| 48 |
+
|
| 49 |
+
/** Demo persona when no farm is registered — must match `findAffectedFarmers` ids */
|
| 50 |
+
const DEMO_FARMER_ID = 'farmer-1'
|
| 51 |
+
|
| 52 |
+
// Recompute when registration changes (useState initializer only runs once)
|
| 53 |
+
const farmers = useMemo<FarmerLocation[]>(() => [
|
| 54 |
+
// Arkansas area farmers
|
| 55 |
+
{
|
| 56 |
+
id: 'farmer-1',
|
| 57 |
+
name: 'John Smith',
|
| 58 |
+
email: 'john@example.com',
|
| 59 |
+
lat: 35.5, // Near Russellville, AR (~20 miles)
|
| 60 |
+
lng: -93.2,
|
| 61 |
+
crops: ['corn', 'wheat', 'soybean'],
|
| 62 |
+
radius: 250,
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
id: 'farmer-2',
|
| 66 |
+
name: 'Sarah Johnson',
|
| 67 |
+
email: 'sarah@example.com',
|
| 68 |
+
lat: 35.1, // Within 250 miles of Russellville (~30 miles)
|
| 69 |
+
lng: -92.8,
|
| 70 |
+
crops: ['corn', 'rice'],
|
| 71 |
+
radius: 250,
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
id: 'farmer-3',
|
| 75 |
+
name: 'Mike Davis',
|
| 76 |
+
email: 'mike@example.com',
|
| 77 |
+
lat: 36.0, // Within 250 miles of Russellville (~50 miles)
|
| 78 |
+
lng: -93.5,
|
| 79 |
+
crops: ['corn', 'wheat', 'soybean'],
|
| 80 |
+
radius: 250,
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
id: 'farmer-4',
|
| 84 |
+
name: 'Arkansas Farm Co.',
|
| 85 |
+
email: 'info@arkfarm.com',
|
| 86 |
+
lat: 34.7, // Little Rock area - within 250 miles (~80 miles)
|
| 87 |
+
lng: -92.3,
|
| 88 |
+
crops: ['corn', 'soybean'],
|
| 89 |
+
radius: 250,
|
| 90 |
+
},
|
| 91 |
+
// California farmers
|
| 92 |
+
{
|
| 93 |
+
id: 'farmer-5',
|
| 94 |
+
name: 'Central Valley Farms',
|
| 95 |
+
email: 'contact@cvfarms.com',
|
| 96 |
+
lat: 36.5, // Near Fresno, CA
|
| 97 |
+
lng: -119.5,
|
| 98 |
+
crops: ['wheat', 'corn'],
|
| 99 |
+
radius: 250,
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
id: 'farmer-6',
|
| 103 |
+
name: 'Golden State Agriculture',
|
| 104 |
+
email: 'info@gsag.com',
|
| 105 |
+
lat: 37.0, // Near Modesto, CA
|
| 106 |
+
lng: -120.5,
|
| 107 |
+
crops: ['wheat', 'corn', 'soybean'],
|
| 108 |
+
radius: 250,
|
| 109 |
+
},
|
| 110 |
+
// Texas farmers
|
| 111 |
+
{
|
| 112 |
+
id: 'farmer-7',
|
| 113 |
+
name: 'Lone Star Crops',
|
| 114 |
+
email: 'hello@lonestarcrops.com',
|
| 115 |
+
lat: 32.0, // Near Abilene, TX
|
| 116 |
+
lng: -99.5,
|
| 117 |
+
crops: ['corn', 'wheat'],
|
| 118 |
+
radius: 250,
|
| 119 |
+
},
|
| 120 |
+
{
|
| 121 |
+
id: 'farmer-8',
|
| 122 |
+
name: 'Texas Grain Co.',
|
| 123 |
+
email: 'info@texasgrain.com',
|
| 124 |
+
lat: 31.5, // Near San Angelo, TX
|
| 125 |
+
lng: -100.0,
|
| 126 |
+
crops: ['corn', 'soybean'],
|
| 127 |
+
radius: 250,
|
| 128 |
+
},
|
| 129 |
+
// Iowa farmers
|
| 130 |
+
{
|
| 131 |
+
id: 'farmer-9',
|
| 132 |
+
name: 'Iowa Corn Growers',
|
| 133 |
+
email: 'contact@iowacorn.com',
|
| 134 |
+
lat: 41.5, // Near Des Moines, IA
|
| 135 |
+
lng: -93.0,
|
| 136 |
+
crops: ['corn', 'soybean'],
|
| 137 |
+
radius: 250,
|
| 138 |
+
},
|
| 139 |
+
{
|
| 140 |
+
id: 'farmer-10',
|
| 141 |
+
name: 'Midwest Agriculture',
|
| 142 |
+
email: 'info@midwestag.com',
|
| 143 |
+
lat: 42.0, // Near Cedar Rapids, IA
|
| 144 |
+
lng: -91.5,
|
| 145 |
+
crops: ['corn', 'soybean', 'wheat'],
|
| 146 |
+
radius: 250,
|
| 147 |
+
},
|
| 148 |
+
// Illinois farmers
|
| 149 |
+
{
|
| 150 |
+
id: 'farmer-11',
|
| 151 |
+
name: 'Prairie Farms',
|
| 152 |
+
email: 'hello@prairiefarms.com',
|
| 153 |
+
lat: 40.0, // Near Champaign, IL
|
| 154 |
+
lng: -88.5,
|
| 155 |
+
crops: ['corn', 'soybean'],
|
| 156 |
+
radius: 250,
|
| 157 |
+
},
|
| 158 |
+
// Kansas farmers
|
| 159 |
+
{
|
| 160 |
+
id: 'farmer-12',
|
| 161 |
+
name: 'Kansas Wheat Growers',
|
| 162 |
+
email: 'info@kswheat.com',
|
| 163 |
+
lat: 38.5, // Near Wichita, KS
|
| 164 |
+
lng: -98.0,
|
| 165 |
+
crops: ['wheat', 'corn'],
|
| 166 |
+
radius: 250,
|
| 167 |
+
},
|
| 168 |
+
{
|
| 169 |
+
id: 'farmer-13',
|
| 170 |
+
name: 'Sunflower State Farms',
|
| 171 |
+
email: 'contact@sunflowerfarms.com',
|
| 172 |
+
lat: 39.0, // Near Topeka, KS
|
| 173 |
+
lng: -95.5,
|
| 174 |
+
crops: ['wheat', 'corn', 'soybean'],
|
| 175 |
+
radius: 250,
|
| 176 |
+
},
|
| 177 |
+
// Nebraska farmers
|
| 178 |
+
{
|
| 179 |
+
id: 'farmer-14',
|
| 180 |
+
name: 'Cornhusker Agriculture',
|
| 181 |
+
email: 'info@cornhuskerag.com',
|
| 182 |
+
lat: 41.0, // Near Lincoln, NE
|
| 183 |
+
lng: -96.5,
|
| 184 |
+
crops: ['corn', 'soybean'],
|
| 185 |
+
radius: 250,
|
| 186 |
+
},
|
| 187 |
+
// North Carolina farmers
|
| 188 |
+
{
|
| 189 |
+
id: 'farmer-15',
|
| 190 |
+
name: 'Carolina Crops',
|
| 191 |
+
email: 'hello@carolinacrops.com',
|
| 192 |
+
lat: 35.5, // Near Charlotte, NC
|
| 193 |
+
lng: -80.5,
|
| 194 |
+
crops: ['corn', 'soybean'],
|
| 195 |
+
radius: 250,
|
| 196 |
+
},
|
| 197 |
+
// Ohio farmers
|
| 198 |
+
{
|
| 199 |
+
id: 'farmer-16',
|
| 200 |
+
name: 'Buckeye Farms',
|
| 201 |
+
email: 'info@buckeyefarms.com',
|
| 202 |
+
lat: 40.0, // Near Columbus, OH
|
| 203 |
+
lng: -83.0,
|
| 204 |
+
crops: ['corn', 'soybean', 'wheat'],
|
| 205 |
+
radius: 250,
|
| 206 |
+
},
|
| 207 |
+
// Add current user if location is available
|
| 208 |
+
...(currentFarmerLocation
|
| 209 |
+
? [
|
| 210 |
+
{
|
| 211 |
+
id: 'current-user',
|
| 212 |
+
name: 'You',
|
| 213 |
+
lat: currentFarmerLocation.lat,
|
| 214 |
+
lng: currentFarmerLocation.lng,
|
| 215 |
+
crops: currentFarmerLocation.crops,
|
| 216 |
+
radius: 250,
|
| 217 |
+
} as FarmerLocation,
|
| 218 |
+
]
|
| 219 |
+
: []),
|
| 220 |
+
], [currentFarmerLocation])
|
| 221 |
+
|
| 222 |
+
const toOutbreakLocation = (report: OutbreakReport): OutbreakLocation => ({
|
| 223 |
+
id: report.id,
|
| 224 |
+
lat: report.lat,
|
| 225 |
+
lng: report.lng,
|
| 226 |
+
crop: report.crop,
|
| 227 |
+
disease: report.disease,
|
| 228 |
+
severity: report.severity,
|
| 229 |
+
date: report.date,
|
| 230 |
+
description: report.description,
|
| 231 |
+
})
|
| 232 |
+
|
| 233 |
+
useEffect(() => {
|
| 234 |
+
if (outbreaks.length === 0) {
|
| 235 |
+
setNotifications([])
|
| 236 |
+
return
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
const targetFarmerId = currentFarmerLocation ? 'current-user' : DEMO_FARMER_ID
|
| 240 |
+
|
| 241 |
+
setNotifications((prev) => {
|
| 242 |
+
const readByOutbreak = new Map<string, boolean>()
|
| 243 |
+
const createdByOutbreak = new Map<string, string>()
|
| 244 |
+
for (const n of prev) {
|
| 245 |
+
if (n.read) readByOutbreak.set(n.outbreakId, true)
|
| 246 |
+
createdByOutbreak.set(n.outbreakId, n.createdAt)
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
const next: Notification[] = []
|
| 250 |
+
for (const report of outbreaks) {
|
| 251 |
+
if (dismissedOutbreakIds.has(report.id)) continue
|
| 252 |
+
|
| 253 |
+
const outbreakLocation = toOutbreakLocation(report)
|
| 254 |
+
const affected = findAffectedFarmers(outbreakLocation, farmers)
|
| 255 |
+
const match = affected.find((a) => a.farmer.id === targetFarmerId)
|
| 256 |
+
if (!match) continue
|
| 257 |
+
|
| 258 |
+
const verifiedTail =
|
| 259 |
+
report.reporterVerified === true
|
| 260 |
+
? ' (verified farmer report)'
|
| 261 |
+
: report.reporterVerified === false
|
| 262 |
+
? ' (unverified farmer report)'
|
| 263 |
+
: ' (community report)'
|
| 264 |
+
|
| 265 |
+
next.push({
|
| 266 |
+
id: `${report.id}-${targetFarmerId}`,
|
| 267 |
+
farmerId: targetFarmerId,
|
| 268 |
+
outbreakId: report.id,
|
| 269 |
+
distance: match.distance,
|
| 270 |
+
message: `${generateNotificationMessage(outbreakLocation, match.distance)}${verifiedTail}`,
|
| 271 |
+
severity: outbreakLocation.severity,
|
| 272 |
+
read: readByOutbreak.get(report.id) ?? false,
|
| 273 |
+
createdAt: createdByOutbreak.get(report.id) ?? new Date().toISOString(),
|
| 274 |
+
})
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
next.sort((a, b) => {
|
| 278 |
+
const s = SEVERITY_SORT[a.severity] - SEVERITY_SORT[b.severity]
|
| 279 |
+
if (s !== 0) return s
|
| 280 |
+
return a.distance - b.distance
|
| 281 |
+
})
|
| 282 |
+
|
| 283 |
+
return next
|
| 284 |
+
})
|
| 285 |
+
}, [outbreaks, farmers, currentFarmerLocation, dismissedOutbreakIds])
|
| 286 |
+
|
| 287 |
+
const markAsRead = (notificationId: string) => {
|
| 288 |
+
setNotifications((prev) =>
|
| 289 |
+
prev.map((notif) =>
|
| 290 |
+
notif.id === notificationId ? { ...notif, read: true } : notif
|
| 291 |
+
)
|
| 292 |
+
)
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
const markAllAsRead = () => {
|
| 296 |
+
setNotifications((prev) => prev.map((notif) => ({ ...notif, read: true })))
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
const deleteNotification = (notificationId: string) => {
|
| 300 |
+
setNotifications((prev) => {
|
| 301 |
+
const n = prev.find((x) => x.id === notificationId)
|
| 302 |
+
if (n) {
|
| 303 |
+
setDismissedOutbreakIds((s) => new Set(s).add(n.outbreakId))
|
| 304 |
+
}
|
| 305 |
+
return prev.filter((x) => x.id !== notificationId)
|
| 306 |
+
})
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
const unreadCount = notifications.filter((n) => !n.read).length
|
| 310 |
+
|
| 311 |
+
return (
|
| 312 |
+
<div
|
| 313 |
+
ref={rootRef}
|
| 314 |
+
className="relative z-[200] inline-flex shrink-0 items-center justify-center self-center"
|
| 315 |
+
>
|
| 316 |
+
<button
|
| 317 |
+
type="button"
|
| 318 |
+
onClick={() => setIsOpen(!isOpen)}
|
| 319 |
+
className="touch-manipulation relative flex min-h-[44px] min-w-[44px] items-center justify-center rounded-xl border-2 border-slate-200 bg-white p-2 shadow-sm transition-all hover:border-primary-400 hover:shadow-md"
|
| 320 |
+
aria-label="Notifications"
|
| 321 |
+
aria-expanded={isOpen}
|
| 322 |
+
aria-haspopup="dialog"
|
| 323 |
+
>
|
| 324 |
+
<Bell className="h-6 w-6 text-slate-700" />
|
| 325 |
+
{unreadCount > 0 && (
|
| 326 |
+
<span className="absolute -right-1 -top-1 flex h-6 w-6 items-center justify-center rounded-full border-2 border-white bg-red-600 text-xs font-bold text-white">
|
| 327 |
+
{unreadCount > 9 ? '9+' : unreadCount}
|
| 328 |
+
</span>
|
| 329 |
+
)}
|
| 330 |
+
</button>
|
| 331 |
+
{isOpen && (
|
| 332 |
+
<div
|
| 333 |
+
role="dialog"
|
| 334 |
+
aria-modal="true"
|
| 335 |
+
aria-labelledby="disease-alerts-title"
|
| 336 |
+
className="absolute right-0 top-[calc(100%+10px)] flex max-h-[min(420px,70dvh)] w-[min(22rem,calc(100vw-1.5rem))] flex-col overflow-hidden rounded-2xl border border-slate-300 bg-white shadow-xl ring-1 ring-slate-900/10"
|
| 337 |
+
>
|
| 338 |
+
<div className="flex shrink-0 items-start justify-between gap-3 border-b border-slate-200 bg-white p-4">
|
| 339 |
+
<div className="min-w-0 flex-1">
|
| 340 |
+
<h3
|
| 341 |
+
id="disease-alerts-title"
|
| 342 |
+
className="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-lg font-bold leading-tight text-slate-900 sm:text-xl"
|
| 343 |
+
>
|
| 344 |
+
<span className="inline-flex shrink-0 items-center gap-2">
|
| 345 |
+
<Bell className="h-5 w-5 shrink-0 text-primary-700" />
|
| 346 |
+
<span>Disease Alerts</span>
|
| 347 |
+
</span>
|
| 348 |
+
{unreadCount > 0 && (
|
| 349 |
+
<span className="inline-flex shrink-0 items-center rounded-full bg-red-600 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-white shadow-sm">
|
| 350 |
+
{unreadCount} new
|
| 351 |
+
</span>
|
| 352 |
+
)}
|
| 353 |
+
</h3>
|
| 354 |
+
</div>
|
| 355 |
+
<div className="flex shrink-0 items-center gap-1.5 sm:gap-2">
|
| 356 |
+
{unreadCount > 0 && (
|
| 357 |
+
<button
|
| 358 |
+
type="button"
|
| 359 |
+
onClick={markAllAsRead}
|
| 360 |
+
className="whitespace-nowrap rounded-lg px-2 py-1.5 text-xs font-semibold text-primary-700 transition-colors hover:bg-primary-50"
|
| 361 |
+
>
|
| 362 |
+
Mark all read
|
| 363 |
+
</button>
|
| 364 |
+
)}
|
| 365 |
+
<button
|
| 366 |
+
type="button"
|
| 367 |
+
onClick={() => setIsOpen(false)}
|
| 368 |
+
className="rounded-lg p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800"
|
| 369 |
+
aria-label="Close notifications"
|
| 370 |
+
>
|
| 371 |
+
<X className="h-5 w-5" />
|
| 372 |
+
</button>
|
| 373 |
+
</div>
|
| 374 |
+
</div>
|
| 375 |
+
|
| 376 |
+
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-white p-2">
|
| 377 |
+
{notifications.length === 0 ? (
|
| 378 |
+
<div className="py-10 text-center text-slate-600">
|
| 379 |
+
<Bell className="mx-auto mb-3 h-12 w-12 text-slate-400" />
|
| 380 |
+
<p className="font-semibold text-slate-800">No alerts yet</p>
|
| 381 |
+
<p className="mt-1 text-sm text-slate-500">
|
| 382 |
+
You'll be notified when outbreaks occur within 250 miles
|
| 383 |
+
</p>
|
| 384 |
+
</div>
|
| 385 |
+
) : (
|
| 386 |
+
<div className="space-y-2">
|
| 387 |
+
{notifications.map((notification) => {
|
| 388 |
+
const outbreak = outbreaks.find((o) => o.id === notification.outbreakId) as
|
| 389 |
+
| OutbreakReport
|
| 390 |
+
| undefined
|
| 391 |
+
return (
|
| 392 |
+
<div
|
| 393 |
+
key={notification.id}
|
| 394 |
+
className={`rounded-xl border p-4 transition-all ${
|
| 395 |
+
notification.read
|
| 396 |
+
? 'border-slate-200 bg-slate-50'
|
| 397 |
+
: 'border-red-200 bg-red-50/95 shadow-sm'
|
| 398 |
+
}`}
|
| 399 |
+
>
|
| 400 |
+
<div className="flex items-start justify-between gap-3">
|
| 401 |
+
<div className="min-w-0 flex-1">
|
| 402 |
+
<div className="mb-2 flex gap-2">
|
| 403 |
+
<AlertTriangle
|
| 404 |
+
className={`mt-0.5 h-5 w-5 shrink-0 ${
|
| 405 |
+
notification.severity === 'high'
|
| 406 |
+
? 'text-red-600'
|
| 407 |
+
: notification.severity === 'medium'
|
| 408 |
+
? 'text-orange-600'
|
| 409 |
+
: 'text-yellow-600'
|
| 410 |
+
}`}
|
| 411 |
+
/>
|
| 412 |
+
<p className="text-sm font-bold leading-snug text-slate-900">
|
| 413 |
+
{notification.message}
|
| 414 |
+
</p>
|
| 415 |
+
</div>
|
| 416 |
+
{outbreak && (
|
| 417 |
+
<div className="mt-2 space-y-1 pl-0 sm:pl-7">
|
| 418 |
+
{outbreak.reporterVerified !== undefined && (
|
| 419 |
+
<p className="text-xs">
|
| 420 |
+
<span
|
| 421 |
+
className={`inline-block rounded-md border px-2 py-0.5 font-bold ${
|
| 422 |
+
outbreak.reporterVerified
|
| 423 |
+
? 'border-emerald-200 bg-emerald-100 text-emerald-900'
|
| 424 |
+
: 'border-slate-200 bg-slate-100 text-slate-700'
|
| 425 |
+
}`}
|
| 426 |
+
>
|
| 427 |
+
{outbreak.reporterVerified ? 'Verified farmer' : 'Unverified farmer'}
|
| 428 |
+
</span>
|
| 429 |
+
</p>
|
| 430 |
+
)}
|
| 431 |
+
<p className="flex items-center gap-1 text-xs text-slate-600">
|
| 432 |
+
<MapPin className="h-3 w-3 shrink-0" />
|
| 433 |
+
{notification.distance.toFixed(1)} miles away
|
| 434 |
+
</p>
|
| 435 |
+
<p className="text-xs text-slate-500">
|
| 436 |
+
{new Date(notification.createdAt).toLocaleString()}
|
| 437 |
+
</p>
|
| 438 |
+
</div>
|
| 439 |
+
)}
|
| 440 |
+
</div>
|
| 441 |
+
<div className="flex shrink-0 items-start gap-1">
|
| 442 |
+
{!notification.read && (
|
| 443 |
+
<button
|
| 444 |
+
type="button"
|
| 445 |
+
onClick={() => markAsRead(notification.id)}
|
| 446 |
+
className="rounded-lg p-1.5 text-green-700 transition-colors hover:bg-white"
|
| 447 |
+
title="Mark as read"
|
| 448 |
+
>
|
| 449 |
+
<Check className="h-4 w-4" />
|
| 450 |
+
</button>
|
| 451 |
+
)}
|
| 452 |
+
<button
|
| 453 |
+
type="button"
|
| 454 |
+
onClick={() => deleteNotification(notification.id)}
|
| 455 |
+
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-800"
|
| 456 |
+
title="Delete"
|
| 457 |
+
>
|
| 458 |
+
<X className="h-4 w-4" />
|
| 459 |
+
</button>
|
| 460 |
+
</div>
|
| 461 |
+
</div>
|
| 462 |
+
</div>
|
| 463 |
+
)
|
| 464 |
+
})}
|
| 465 |
+
</div>
|
| 466 |
+
)}
|
| 467 |
+
</div>
|
| 468 |
+
|
| 469 |
+
{notifications.length > 0 && (
|
| 470 |
+
<div className="shrink-0 border-t border-slate-200 bg-slate-100/90 p-3">
|
| 471 |
+
<p className="text-center text-xs text-slate-600">
|
| 472 |
+
Alerts within 250 miles of your farm (or demo location). Dismissed alerts stay hidden until refresh.
|
| 473 |
+
</p>
|
| 474 |
+
</div>
|
| 475 |
+
)}
|
| 476 |
+
</div>
|
| 477 |
+
)}
|
| 478 |
+
</div>
|
| 479 |
+
)
|
| 480 |
+
}
|
components/OutbreakMap.tsx
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from 'react'
|
| 4 |
+
import { WorldMap } from '@/components/ui/world-map'
|
| 5 |
+
import { MapPin, AlertTriangle, X, Plus, Save } from 'lucide-react'
|
| 6 |
+
import { motion } from 'framer-motion'
|
| 7 |
+
|
| 8 |
+
interface OutbreakReport {
|
| 9 |
+
id: string
|
| 10 |
+
lat: number
|
| 11 |
+
lng: number
|
| 12 |
+
crop: string
|
| 13 |
+
disease: string
|
| 14 |
+
severity: 'low' | 'medium' | 'high'
|
| 15 |
+
date: string
|
| 16 |
+
description: string
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export default function OutbreakMap() {
|
| 20 |
+
const [reports, setReports] = useState<OutbreakReport[]>([])
|
| 21 |
+
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lng: number } | null>(null)
|
| 22 |
+
const [showReportForm, setShowReportForm] = useState(false)
|
| 23 |
+
const [formData, setFormData] = useState({
|
| 24 |
+
crop: '',
|
| 25 |
+
disease: '',
|
| 26 |
+
severity: 'medium' as 'low' | 'medium' | 'high',
|
| 27 |
+
description: '',
|
| 28 |
+
})
|
| 29 |
+
|
| 30 |
+
// Convert reports to map dots format
|
| 31 |
+
const mapDots = reports.map((report) => ({
|
| 32 |
+
start: { lat: report.lat, lng: report.lng, label: report.disease },
|
| 33 |
+
end: { lat: report.lat, lng: report.lng, label: report.disease },
|
| 34 |
+
}))
|
| 35 |
+
|
| 36 |
+
const handleMapClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
| 37 |
+
// Find the map container
|
| 38 |
+
const mapContainer = e.currentTarget.querySelector('[class*="aspect-[2/1]"]')
|
| 39 |
+
if (!mapContainer) return
|
| 40 |
+
|
| 41 |
+
const rect = mapContainer.getBoundingClientRect()
|
| 42 |
+
const x = e.clientX - rect.left
|
| 43 |
+
const y = e.clientY - rect.top
|
| 44 |
+
|
| 45 |
+
// Convert pixel coordinates to lat/lng (map is 800x400 viewBox)
|
| 46 |
+
const lng = (x / rect.width) * 360 - 180
|
| 47 |
+
const lat = 90 - (y / rect.height) * 180
|
| 48 |
+
|
| 49 |
+
// Clamp values to valid ranges
|
| 50 |
+
const clampedLat = Math.max(-90, Math.min(90, lat))
|
| 51 |
+
const clampedLng = Math.max(-180, Math.min(180, lng))
|
| 52 |
+
|
| 53 |
+
setSelectedLocation({ lat: clampedLat, lng: clampedLng })
|
| 54 |
+
setShowReportForm(true)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const handleSubmitReport = () => {
|
| 58 |
+
if (!selectedLocation || !formData.crop || !formData.disease) {
|
| 59 |
+
alert('Please fill in all required fields')
|
| 60 |
+
return
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
const newReport: OutbreakReport = {
|
| 64 |
+
id: Date.now().toString(),
|
| 65 |
+
lat: selectedLocation.lat,
|
| 66 |
+
lng: selectedLocation.lng,
|
| 67 |
+
crop: formData.crop,
|
| 68 |
+
disease: formData.disease,
|
| 69 |
+
severity: formData.severity,
|
| 70 |
+
date: new Date().toISOString(),
|
| 71 |
+
description: formData.description,
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
setReports([...reports, newReport])
|
| 75 |
+
setShowReportForm(false)
|
| 76 |
+
setSelectedLocation(null)
|
| 77 |
+
setFormData({
|
| 78 |
+
crop: '',
|
| 79 |
+
disease: '',
|
| 80 |
+
severity: 'medium',
|
| 81 |
+
description: '',
|
| 82 |
+
})
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
const getSeverityColor = (severity: string) => {
|
| 86 |
+
switch (severity) {
|
| 87 |
+
case 'high':
|
| 88 |
+
return 'text-red-600 bg-red-50 border-red-200'
|
| 89 |
+
case 'medium':
|
| 90 |
+
return 'text-orange-600 bg-orange-50 border-orange-200'
|
| 91 |
+
case 'low':
|
| 92 |
+
return 'text-yellow-600 bg-yellow-50 border-yellow-200'
|
| 93 |
+
default:
|
| 94 |
+
return 'text-gray-600 bg-gray-50 border-gray-200'
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
return (
|
| 99 |
+
<div className="min-h-screen min-h-[100dvh] bg-gradient-to-br from-slate-50 via-blue-50 to-slate-100 py-6 sm:py-8 px-3 sm:px-4 pb-[max(1.5rem,env(safe-area-inset-bottom))] pt-[max(0.75rem,env(safe-area-inset-top))]">
|
| 100 |
+
<div className="container mx-auto max-w-7xl">
|
| 101 |
+
{/* Header */}
|
| 102 |
+
<div className="mb-8 text-center">
|
| 103 |
+
<h1 className="text-4xl md:text-5xl font-bold text-slate-900 mb-3">
|
| 104 |
+
Outbreak Reporting System
|
| 105 |
+
</h1>
|
| 106 |
+
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
|
| 107 |
+
Click on the map to report potential crop disease outbreaks in your area
|
| 108 |
+
</p>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
{/* Map Container */}
|
| 112 |
+
<div className="bg-white rounded-xl shadow-lg p-6 mb-6 border border-slate-200">
|
| 113 |
+
<div className="relative">
|
| 114 |
+
<WorldMap dots={mapDots} lineColor="#ef4444" />
|
| 115 |
+
{/* Clickable overlay for map interaction */}
|
| 116 |
+
<div
|
| 117 |
+
onClick={handleMapClick}
|
| 118 |
+
className="absolute inset-0 cursor-crosshair z-10"
|
| 119 |
+
style={{ pointerEvents: 'auto' }}
|
| 120 |
+
/>
|
| 121 |
+
{selectedLocation && (
|
| 122 |
+
<div className="absolute top-4 right-4 bg-white rounded-lg shadow-lg p-4 border border-slate-200 z-20">
|
| 123 |
+
<p className="text-sm font-semibold text-slate-900 mb-1">Selected Location</p>
|
| 124 |
+
<p className="text-xs text-slate-600">
|
| 125 |
+
Lat: {selectedLocation.lat.toFixed(4)}, Lng: {selectedLocation.lng.toFixed(4)}
|
| 126 |
+
</p>
|
| 127 |
+
</div>
|
| 128 |
+
)}
|
| 129 |
+
<div className="absolute bottom-4 left-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg z-20">
|
| 130 |
+
<p className="text-sm font-semibold flex items-center gap-2">
|
| 131 |
+
<MapPin className="w-4 h-4" />
|
| 132 |
+
Click on map to report outbreak
|
| 133 |
+
</p>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
{/* Report Form Modal — avoid motion opacity on full-screen layer (invisible overlays still capture clicks). */}
|
| 139 |
+
{showReportForm && (
|
| 140 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
| 141 |
+
<div
|
| 142 |
+
role="presentation"
|
| 143 |
+
className="absolute inset-0"
|
| 144 |
+
onClick={() => {
|
| 145 |
+
setShowReportForm(false)
|
| 146 |
+
setSelectedLocation(null)
|
| 147 |
+
}}
|
| 148 |
+
/>
|
| 149 |
+
<div
|
| 150 |
+
role="dialog"
|
| 151 |
+
aria-modal="true"
|
| 152 |
+
onClick={(e) => e.stopPropagation()}
|
| 153 |
+
className="relative z-10 max-h-[90dvh] w-full max-w-md overflow-y-auto overscroll-contain rounded-xl border border-slate-200 bg-white p-6 shadow-2xl"
|
| 154 |
+
>
|
| 155 |
+
<div className="flex items-center justify-between mb-4">
|
| 156 |
+
<h2 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
| 157 |
+
<AlertTriangle className="w-6 h-6 text-orange-500" />
|
| 158 |
+
Report Outbreak
|
| 159 |
+
</h2>
|
| 160 |
+
<button
|
| 161 |
+
onClick={() => {
|
| 162 |
+
setShowReportForm(false)
|
| 163 |
+
setSelectedLocation(null)
|
| 164 |
+
}}
|
| 165 |
+
className="text-slate-400 hover:text-slate-600 transition-colors"
|
| 166 |
+
>
|
| 167 |
+
<X className="w-5 h-5" />
|
| 168 |
+
</button>
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
<div className="space-y-4">
|
| 172 |
+
<div>
|
| 173 |
+
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
| 174 |
+
Crop Type *
|
| 175 |
+
</label>
|
| 176 |
+
<input
|
| 177 |
+
type="text"
|
| 178 |
+
value={formData.crop}
|
| 179 |
+
onChange={(e) => setFormData({ ...formData, crop: e.target.value })}
|
| 180 |
+
placeholder="e.g., Corn, Wheat, Rice"
|
| 181 |
+
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
| 182 |
+
/>
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
<div>
|
| 186 |
+
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
| 187 |
+
Disease Name *
|
| 188 |
+
</label>
|
| 189 |
+
<input
|
| 190 |
+
type="text"
|
| 191 |
+
value={formData.disease}
|
| 192 |
+
onChange={(e) => setFormData({ ...formData, disease: e.target.value })}
|
| 193 |
+
placeholder="e.g., Rust, Blight, Mosaic"
|
| 194 |
+
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
| 195 |
+
/>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<div>
|
| 199 |
+
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
| 200 |
+
Severity *
|
| 201 |
+
</label>
|
| 202 |
+
<select
|
| 203 |
+
value={formData.severity}
|
| 204 |
+
onChange={(e) =>
|
| 205 |
+
setFormData({
|
| 206 |
+
...formData,
|
| 207 |
+
severity: e.target.value as 'low' | 'medium' | 'high',
|
| 208 |
+
})
|
| 209 |
+
}
|
| 210 |
+
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
| 211 |
+
>
|
| 212 |
+
<option value="low">Low</option>
|
| 213 |
+
<option value="medium">Medium</option>
|
| 214 |
+
<option value="high">High</option>
|
| 215 |
+
</select>
|
| 216 |
+
</div>
|
| 217 |
+
|
| 218 |
+
<div>
|
| 219 |
+
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
| 220 |
+
Description
|
| 221 |
+
</label>
|
| 222 |
+
<textarea
|
| 223 |
+
value={formData.description}
|
| 224 |
+
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
| 225 |
+
placeholder="Additional details about the outbreak..."
|
| 226 |
+
rows={3}
|
| 227 |
+
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
| 228 |
+
/>
|
| 229 |
+
</div>
|
| 230 |
+
|
| 231 |
+
<button
|
| 232 |
+
onClick={handleSubmitReport}
|
| 233 |
+
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-6 rounded-lg transition-colors flex items-center justify-center gap-2 shadow-lg"
|
| 234 |
+
>
|
| 235 |
+
<Save className="w-5 h-5" />
|
| 236 |
+
Submit Report
|
| 237 |
+
</button>
|
| 238 |
+
</div>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
)}
|
| 242 |
+
|
| 243 |
+
{/* Reports List */}
|
| 244 |
+
<div className="bg-white rounded-xl shadow-lg p-6 border border-slate-200">
|
| 245 |
+
<h2 className="text-2xl font-bold text-slate-900 mb-4 flex items-center gap-2">
|
| 246 |
+
<MapPin className="w-6 h-6 text-blue-600" />
|
| 247 |
+
Reported Outbreaks ({reports.length})
|
| 248 |
+
</h2>
|
| 249 |
+
|
| 250 |
+
{reports.length === 0 ? (
|
| 251 |
+
<div className="text-center py-12 text-slate-500">
|
| 252 |
+
<MapPin className="w-12 h-12 mx-auto mb-4 text-slate-300" />
|
| 253 |
+
<p>No outbreaks reported yet. Click on the map to report one.</p>
|
| 254 |
+
</div>
|
| 255 |
+
) : (
|
| 256 |
+
<div className="space-y-4">
|
| 257 |
+
{reports.map((report) => (
|
| 258 |
+
<motion.div
|
| 259 |
+
key={report.id}
|
| 260 |
+
initial={{ opacity: 0, y: 20 }}
|
| 261 |
+
animate={{ opacity: 1, y: 0 }}
|
| 262 |
+
className="border border-slate-200 rounded-lg p-4 hover:shadow-md transition-shadow"
|
| 263 |
+
>
|
| 264 |
+
<div className="flex items-start justify-between">
|
| 265 |
+
<div className="flex-1">
|
| 266 |
+
<div className="flex items-center gap-3 mb-2">
|
| 267 |
+
<h3 className="text-lg font-semibold text-slate-900">
|
| 268 |
+
{report.crop} - {report.disease}
|
| 269 |
+
</h3>
|
| 270 |
+
<span
|
| 271 |
+
className={`px-3 py-1 rounded-full text-xs font-semibold border ${getSeverityColor(
|
| 272 |
+
report.severity
|
| 273 |
+
)}`}
|
| 274 |
+
>
|
| 275 |
+
{report.severity.toUpperCase()}
|
| 276 |
+
</span>
|
| 277 |
+
</div>
|
| 278 |
+
<p className="text-sm text-slate-600 mb-2">
|
| 279 |
+
Location: {report.lat.toFixed(4)}, {report.lng.toFixed(4)}
|
| 280 |
+
</p>
|
| 281 |
+
{report.description && (
|
| 282 |
+
<p className="text-sm text-slate-700">{report.description}</p>
|
| 283 |
+
)}
|
| 284 |
+
<p className="text-xs text-slate-500 mt-2">
|
| 285 |
+
Reported: {new Date(report.date).toLocaleString()}
|
| 286 |
+
</p>
|
| 287 |
+
</div>
|
| 288 |
+
</div>
|
| 289 |
+
</motion.div>
|
| 290 |
+
))}
|
| 291 |
+
</div>
|
| 292 |
+
)}
|
| 293 |
+
</div>
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
)
|
| 297 |
+
}
|
components/PredictionHistory.tsx
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from 'react'
|
| 4 |
+
|
| 5 |
+
interface PredictionRecord {
|
| 6 |
+
id: string
|
| 7 |
+
timestamp: string
|
| 8 |
+
crop: string
|
| 9 |
+
disease: string
|
| 10 |
+
confidence: number
|
| 11 |
+
imageUrl: string
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
interface PredictionHistoryProps {
|
| 15 |
+
onSelectHistory: (record: PredictionRecord) => void
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export default function PredictionHistory({ onSelectHistory }: PredictionHistoryProps) {
|
| 19 |
+
const [history, setHistory] = useState<PredictionRecord[]>([])
|
| 20 |
+
const [isOpen, setIsOpen] = useState(false)
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
// Load history from localStorage (only in browser)
|
| 24 |
+
if (typeof window === 'undefined') return
|
| 25 |
+
|
| 26 |
+
try {
|
| 27 |
+
const saved = localStorage.getItem('cropintel_history')
|
| 28 |
+
if (saved) {
|
| 29 |
+
const parsed = JSON.parse(saved)
|
| 30 |
+
// Validate it's an array
|
| 31 |
+
if (Array.isArray(parsed)) {
|
| 32 |
+
setHistory(parsed)
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
} catch (e) {
|
| 36 |
+
console.error('Failed to load history:', e)
|
| 37 |
+
// Clear corrupted data
|
| 38 |
+
localStorage.removeItem('cropintel_history')
|
| 39 |
+
}
|
| 40 |
+
}, [])
|
| 41 |
+
|
| 42 |
+
const clearHistory = () => {
|
| 43 |
+
if (typeof window === 'undefined') return
|
| 44 |
+
if (confirm('Are you sure you want to clear all prediction history?')) {
|
| 45 |
+
localStorage.removeItem('cropintel_history')
|
| 46 |
+
setHistory([])
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
if (history.length === 0) {
|
| 51 |
+
return (
|
| 52 |
+
<div className="bg-white rounded-2xl border-2 border-slate-200 p-6 shadow-xl">
|
| 53 |
+
<div className="flex items-center justify-between mb-4">
|
| 54 |
+
<h3 className="text-xl font-extrabold text-gray-900 flex items-center gap-2">
|
| 55 |
+
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 56 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 57 |
+
</svg>
|
| 58 |
+
Prediction History
|
| 59 |
+
</h3>
|
| 60 |
+
<button
|
| 61 |
+
onClick={() => setIsOpen(!isOpen)}
|
| 62 |
+
className="text-primary-600 hover:text-primary-700 font-semibold px-3 py-1 rounded-lg hover:bg-primary-50 transition-colors"
|
| 63 |
+
>
|
| 64 |
+
{isOpen ? 'Hide' : 'Show'}
|
| 65 |
+
</button>
|
| 66 |
+
</div>
|
| 67 |
+
{isOpen && (
|
| 68 |
+
<p className="text-gray-600 text-center py-4">No predictions yet. Your prediction history will appear here.</p>
|
| 69 |
+
)}
|
| 70 |
+
</div>
|
| 71 |
+
)
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
return (
|
| 75 |
+
<div className="bg-white rounded-2xl border-2 border-slate-200 p-6 shadow-xl">
|
| 76 |
+
<div className="flex items-center justify-between mb-4">
|
| 77 |
+
<h3 className="text-xl font-extrabold text-gray-900 flex items-center gap-2">
|
| 78 |
+
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 79 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 80 |
+
</svg>
|
| 81 |
+
Prediction History ({history.length})
|
| 82 |
+
</h3>
|
| 83 |
+
<div className="flex gap-3">
|
| 84 |
+
<button
|
| 85 |
+
onClick={() => setIsOpen(!isOpen)}
|
| 86 |
+
className="text-primary-600 hover:text-primary-700 font-semibold px-4 py-2 rounded-lg hover:bg-primary-50 transition-colors"
|
| 87 |
+
>
|
| 88 |
+
{isOpen ? 'Hide' : 'Show'}
|
| 89 |
+
</button>
|
| 90 |
+
<button
|
| 91 |
+
onClick={clearHistory}
|
| 92 |
+
className="text-red-600 hover:text-red-700 font-semibold px-4 py-2 rounded-lg hover:bg-red-50 transition-colors"
|
| 93 |
+
>
|
| 94 |
+
Clear
|
| 95 |
+
</button>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
{isOpen && (
|
| 100 |
+
<div className="space-y-3 max-h-96 overflow-y-auto">
|
| 101 |
+
{history.map((record) => (
|
| 102 |
+
<div
|
| 103 |
+
key={record.id}
|
| 104 |
+
onClick={() => onSelectHistory(record)}
|
| 105 |
+
className="p-5 bg-white/80 rounded-xl border-2 border-gray-200 hover:border-primary-400 hover:bg-gradient-to-r hover:from-primary-50 hover:to-blue-50 cursor-pointer transition-all duration-300 shadow-md hover:shadow-xl transform hover:scale-[1.02]"
|
| 106 |
+
>
|
| 107 |
+
<div className="flex items-center gap-4">
|
| 108 |
+
<div className="relative">
|
| 109 |
+
<img
|
| 110 |
+
src={record.imageUrl}
|
| 111 |
+
alt="History"
|
| 112 |
+
className="w-20 h-20 object-cover rounded-xl border-2 border-gray-300 shadow-md"
|
| 113 |
+
style={{ display: 'block' }}
|
| 114 |
+
onError={(e) => {
|
| 115 |
+
console.error('Image failed to load:', record.imageUrl)
|
| 116 |
+
}}
|
| 117 |
+
/>
|
| 118 |
+
<div className="absolute -top-1 -right-1 w-6 h-6 bg-primary-600 rounded-full border-2 border-white flex items-center justify-center">
|
| 119 |
+
<span className="text-white text-xs font-bold">{Math.round(record.confidence)}</span>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
<div className="flex-1">
|
| 123 |
+
<div className="flex items-center justify-between mb-2">
|
| 124 |
+
<span className="font-bold text-gray-900 text-lg">{record.disease}</span>
|
| 125 |
+
<span className="text-xs text-gray-500 font-medium">
|
| 126 |
+
{new Date(record.timestamp).toLocaleDateString()}
|
| 127 |
+
</span>
|
| 128 |
+
</div>
|
| 129 |
+
<div className="flex items-center gap-4">
|
| 130 |
+
<span className="text-sm font-semibold text-gray-700 capitalize bg-gray-100 px-3 py-1 rounded-lg">
|
| 131 |
+
{record.crop}
|
| 132 |
+
</span>
|
| 133 |
+
<span className="text-sm font-bold text-primary-600">
|
| 134 |
+
{record.confidence.toFixed(1)}% confidence
|
| 135 |
+
</span>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
))}
|
| 141 |
+
</div>
|
| 142 |
+
)}
|
| 143 |
+
</div>
|
| 144 |
+
)
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
export function savePredictionToHistory(
|
| 148 |
+
crop: string,
|
| 149 |
+
disease: string,
|
| 150 |
+
confidence: number,
|
| 151 |
+
imageUrl: string
|
| 152 |
+
) {
|
| 153 |
+
// Only run in browser
|
| 154 |
+
if (typeof window === 'undefined') return
|
| 155 |
+
|
| 156 |
+
try {
|
| 157 |
+
const record: PredictionRecord = {
|
| 158 |
+
id: Date.now().toString(),
|
| 159 |
+
timestamp: new Date().toISOString(),
|
| 160 |
+
crop,
|
| 161 |
+
disease,
|
| 162 |
+
confidence,
|
| 163 |
+
imageUrl
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
const saved = localStorage.getItem('cropintel_history')
|
| 167 |
+
let history: PredictionRecord[] = []
|
| 168 |
+
|
| 169 |
+
if (saved) {
|
| 170 |
+
try {
|
| 171 |
+
const parsed = JSON.parse(saved)
|
| 172 |
+
// Validate it's an array
|
| 173 |
+
if (Array.isArray(parsed)) {
|
| 174 |
+
history = parsed
|
| 175 |
+
} else {
|
| 176 |
+
// If corrupted, start fresh
|
| 177 |
+
history = []
|
| 178 |
+
}
|
| 179 |
+
} catch (e) {
|
| 180 |
+
console.error('Failed to parse history:', e)
|
| 181 |
+
// Clear corrupted data and start fresh
|
| 182 |
+
localStorage.removeItem('cropintel_history')
|
| 183 |
+
history = []
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
// Add new record at the beginning
|
| 188 |
+
history.unshift(record)
|
| 189 |
+
|
| 190 |
+
// Keep only last 50 records
|
| 191 |
+
if (history.length > 50) {
|
| 192 |
+
history = history.slice(0, 50)
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
localStorage.setItem('cropintel_history', JSON.stringify(history))
|
| 196 |
+
} catch (e) {
|
| 197 |
+
console.error('Failed to save prediction to history:', e)
|
| 198 |
+
}
|
| 199 |
+
}
|
components/PredictionResults.tsx
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { AlertTriangle, CheckCircle2 } from 'lucide-react'
|
| 4 |
+
|
| 5 |
+
interface Prediction {
|
| 6 |
+
disease: string
|
| 7 |
+
confidence: number
|
| 8 |
+
is_healthy: boolean
|
| 9 |
+
meets_threshold: boolean
|
| 10 |
+
/** True when the model can't confidently match any disease in our catalog. */
|
| 11 |
+
not_in_catalog?: boolean
|
| 12 |
+
/** Farmer-facing explanation shown when not_in_catalog is true. */
|
| 13 |
+
catalog_message?: string
|
| 14 |
+
all_predictions: Array<{
|
| 15 |
+
disease: string
|
| 16 |
+
confidence: number
|
| 17 |
+
}>
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
interface PredictionResultsProps {
|
| 21 |
+
prediction: Prediction
|
| 22 |
+
/** Shown when regional disease filter was applied */
|
| 23 |
+
regionNote?: string
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/** Model may send 0–1 or 0–100; UI always shows percent to one decimal. */
|
| 27 |
+
function toConfidencePercent(value: number): number {
|
| 28 |
+
if (value > 0 && value <= 1) return value * 100
|
| 29 |
+
return value
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export default function PredictionResults({
|
| 33 |
+
prediction,
|
| 34 |
+
regionNote,
|
| 35 |
+
}: PredictionResultsProps) {
|
| 36 |
+
const getStatusColor = () => {
|
| 37 |
+
if (prediction.not_in_catalog) {
|
| 38 |
+
return 'bg-amber-50 text-amber-900 border-amber-200'
|
| 39 |
+
}
|
| 40 |
+
if (prediction.is_healthy) {
|
| 41 |
+
return 'bg-emerald-50 text-emerald-900 border-emerald-200'
|
| 42 |
+
}
|
| 43 |
+
if (prediction.meets_threshold) {
|
| 44 |
+
return 'bg-rose-50 text-rose-900 border-rose-200'
|
| 45 |
+
}
|
| 46 |
+
return 'bg-amber-50 text-amber-900 border-amber-200'
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const getStatusText = () => {
|
| 50 |
+
if (prediction.not_in_catalog) {
|
| 51 |
+
return 'No match in catalog'
|
| 52 |
+
}
|
| 53 |
+
if (prediction.is_healthy) {
|
| 54 |
+
return 'Healthy'
|
| 55 |
+
}
|
| 56 |
+
if (prediction.meets_threshold) {
|
| 57 |
+
return 'Disease detected'
|
| 58 |
+
}
|
| 59 |
+
return 'Low confidence'
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<div className="mt-8 p-6 bg-white rounded-2xl border border-slate-200/80 shadow-sm">
|
| 64 |
+
<h2 className="text-lg font-semibold text-slate-900 mb-5 flex items-center gap-2">
|
| 65 |
+
<svg className="w-8 h-8 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 66 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 67 |
+
</svg>
|
| 68 |
+
Results
|
| 69 |
+
</h2>
|
| 70 |
+
{regionNote && (
|
| 71 |
+
<p className="text-xs text-slate-600 mb-4 -mt-2 px-1 py-2 rounded-lg bg-sky-50 border border-sky-100">
|
| 72 |
+
{regionNote}
|
| 73 |
+
</p>
|
| 74 |
+
)}
|
| 75 |
+
|
| 76 |
+
{/* Main Result */}
|
| 77 |
+
<div className="rounded-xl p-5 mb-6 border border-slate-200 bg-slate-50/60">
|
| 78 |
+
<div className="flex items-center justify-between mb-4">
|
| 79 |
+
<div>
|
| 80 |
+
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
| 81 |
+
Top label
|
| 82 |
+
</h3>
|
| 83 |
+
<p className="text-2xl font-semibold text-slate-900 mt-1">
|
| 84 |
+
{prediction.disease}
|
| 85 |
+
</p>
|
| 86 |
+
</div>
|
| 87 |
+
<div className="text-right">
|
| 88 |
+
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
| 89 |
+
Confidence
|
| 90 |
+
</h3>
|
| 91 |
+
<p className="text-2xl font-semibold text-primary-700 mt-1 tabular-nums">
|
| 92 |
+
{Math.min(100, Math.max(0, toConfidencePercent(prediction.confidence))).toFixed(1)}%
|
| 93 |
+
</p>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
{/* Status Badge */}
|
| 98 |
+
<div
|
| 99 |
+
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-xl border font-semibold text-sm ${getStatusColor()}`}
|
| 100 |
+
>
|
| 101 |
+
{prediction.is_healthy ? (
|
| 102 |
+
<CheckCircle2 className="w-4 h-4" />
|
| 103 |
+
) : (
|
| 104 |
+
<AlertTriangle className="w-4 h-4" />
|
| 105 |
+
)}
|
| 106 |
+
{getStatusText()}
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
{/* Not-in-catalog notice: model couldn't confidently match any known disease */}
|
| 111 |
+
{prediction.not_in_catalog && (
|
| 112 |
+
<div className="rounded-xl p-4 mb-6 border border-amber-200 bg-amber-50 text-amber-900">
|
| 113 |
+
<div className="flex items-start gap-2">
|
| 114 |
+
<AlertTriangle className="w-5 h-5 shrink-0 mt-0.5" />
|
| 115 |
+
<div>
|
| 116 |
+
<p className="font-semibold text-sm">This may not be a disease we detect</p>
|
| 117 |
+
<p className="text-sm mt-1 leading-snug">
|
| 118 |
+
{prediction.catalog_message ||
|
| 119 |
+
"The image doesn't clearly match any disease in our catalog for this crop. The labels below are the closest guesses, shown for reference only — treat with caution and consider an agricultural expert."}
|
| 120 |
+
</p>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
)}
|
| 125 |
+
|
| 126 |
+
{/* All Predictions */}
|
| 127 |
+
<div>
|
| 128 |
+
<h3 className="text-sm font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
| 129 |
+
<svg className="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 130 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
| 131 |
+
</svg>
|
| 132 |
+
Other labels
|
| 133 |
+
</h3>
|
| 134 |
+
<div className="space-y-3">
|
| 135 |
+
{prediction.all_predictions.map((pred, index) => {
|
| 136 |
+
const pctRaw = toConfidencePercent(pred.confidence)
|
| 137 |
+
const pctClamped = Math.min(100, Math.max(0, pctRaw))
|
| 138 |
+
const pctOneDecimal = pctClamped.toFixed(1)
|
| 139 |
+
return (
|
| 140 |
+
<div
|
| 141 |
+
key={index}
|
| 142 |
+
className="bg-white rounded-xl border border-slate-200 p-4 transition-all duration-200 hover:border-primary-300 hover:bg-slate-50/50"
|
| 143 |
+
>
|
| 144 |
+
{/* Stacked on phones; row layout from md up */}
|
| 145 |
+
<div className="flex flex-col gap-3 md:flex-row md:items-center md:gap-4">
|
| 146 |
+
<p className="min-w-0 flex-1 text-left text-sm font-medium leading-snug text-slate-900 md:text-base">
|
| 147 |
+
{pred.disease}
|
| 148 |
+
</p>
|
| 149 |
+
<div className="flex w-full min-w-0 items-center gap-3 md:max-w-md md:flex-[1_1_40%]">
|
| 150 |
+
<div className="min-h-2 min-w-0 flex-1 overflow-hidden rounded-full bg-slate-200">
|
| 151 |
+
<div
|
| 152 |
+
className="h-2 max-w-full rounded-full bg-gradient-to-r from-primary-600 to-blue-600 transition-all duration-500"
|
| 153 |
+
style={{ width: `${pctClamped}%` }}
|
| 154 |
+
/>
|
| 155 |
+
</div>
|
| 156 |
+
<span className="min-w-[4.5rem] shrink-0 text-right text-sm font-semibold tabular-nums text-slate-700 md:text-base">
|
| 157 |
+
{pctOneDecimal}%
|
| 158 |
+
</span>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
)
|
| 163 |
+
})}
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
)
|
| 168 |
+
}
|
components/StateSelector.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { MapPinned } from 'lucide-react'
|
| 4 |
+
import { US_STATES } from '@/lib/stateDiseaseMap'
|
| 5 |
+
|
| 6 |
+
interface StateSelectorProps {
|
| 7 |
+
selectedState: string
|
| 8 |
+
onStateChange: (code: string) => void
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export default function StateSelector({ selectedState, onStateChange }: StateSelectorProps) {
|
| 12 |
+
return (
|
| 13 |
+
<div>
|
| 14 |
+
<label
|
| 15 |
+
htmlFor="state-select"
|
| 16 |
+
className="block text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2"
|
| 17 |
+
>
|
| 18 |
+
<MapPinned className="w-4 h-4 text-primary-700" />
|
| 19 |
+
State
|
| 20 |
+
</label>
|
| 21 |
+
<select
|
| 22 |
+
id="state-select"
|
| 23 |
+
value={selectedState}
|
| 24 |
+
onChange={(e) => onStateChange(e.target.value)}
|
| 25 |
+
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-4 focus:ring-primary-200/60 focus:border-primary-400 text-base font-semibold bg-white shadow-sm hover:shadow-md transition-all duration-200 cursor-pointer"
|
| 26 |
+
>
|
| 27 |
+
{US_STATES.map(({ code, name }) => (
|
| 28 |
+
<option key={code} value={code}>
|
| 29 |
+
{name} ({code})
|
| 30 |
+
</option>
|
| 31 |
+
))}
|
| 32 |
+
</select>
|
| 33 |
+
<p className="text-xs text-slate-500 mt-1.5">
|
| 34 |
+
Regional filter applies when we have a disease list for this crop and state; otherwise all labels are shown.
|
| 35 |
+
</p>
|
| 36 |
+
</div>
|
| 37 |
+
)
|
| 38 |
+
}
|
components/TipsAndGuidelines.tsx
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useState } from 'react'
|
| 4 |
+
import { Camera, ChevronDown, CheckCircle2, TriangleAlert } from 'lucide-react'
|
| 5 |
+
|
| 6 |
+
export default function TipsAndGuidelines() {
|
| 7 |
+
const [isOpen, setIsOpen] = useState(false)
|
| 8 |
+
|
| 9 |
+
return (
|
| 10 |
+
<div className="bg-white rounded-2xl border border-slate-200/80 p-4 sm:p-6 shadow-sm">
|
| 11 |
+
<button
|
| 12 |
+
onClick={() => setIsOpen(!isOpen)}
|
| 13 |
+
className="flex items-center justify-between w-full text-left group gap-4"
|
| 14 |
+
>
|
| 15 |
+
<h3 className="text-base font-semibold text-slate-900 flex items-center gap-2">
|
| 16 |
+
<span className="p-2 rounded-xl bg-primary-50 border border-primary-100">
|
| 17 |
+
<Camera className="w-4 h-4 text-primary-700" />
|
| 18 |
+
</span>
|
| 19 |
+
<span>Capture tips</span>
|
| 20 |
+
</h3>
|
| 21 |
+
<ChevronDown
|
| 22 |
+
className={`w-5 h-5 text-slate-600 transition-transform duration-200 group-hover:text-primary-700 ${isOpen ? 'rotate-180' : ''}`}
|
| 23 |
+
/>
|
| 24 |
+
</button>
|
| 25 |
+
|
| 26 |
+
{isOpen && (
|
| 27 |
+
<div className="mt-5 space-y-4 text-slate-700">
|
| 28 |
+
<div className="rounded-xl p-4 border border-slate-200 bg-slate-50/60">
|
| 29 |
+
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
| 30 |
+
<Camera className="w-4 h-4 text-primary-700" />
|
| 31 |
+
Image quality
|
| 32 |
+
</h4>
|
| 33 |
+
<ul className="space-y-2">
|
| 34 |
+
{['Use clear, well-lit photos', 'Ensure the leaf/disease area is in focus', 'Avoid blurry or dark images', 'Take photos in natural daylight when possible'].map((tip, i) => (
|
| 35 |
+
<li key={i} className="flex items-start gap-2">
|
| 36 |
+
<CheckCircle2 className="w-4 h-4 text-primary-700 mt-0.5 flex-shrink-0" />
|
| 37 |
+
<span>{tip}</span>
|
| 38 |
+
</li>
|
| 39 |
+
))}
|
| 40 |
+
</ul>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<div className="rounded-xl p-4 border border-slate-200 bg-slate-50/60">
|
| 44 |
+
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
| 45 |
+
<span className="inline-flex items-center justify-center w-6 h-6 rounded-lg bg-white border border-slate-200 text-xs font-bold text-slate-700">
|
| 46 |
+
2
|
| 47 |
+
</span>
|
| 48 |
+
What to capture
|
| 49 |
+
</h4>
|
| 50 |
+
<ul className="space-y-2">
|
| 51 |
+
{['Focus on the affected area of the plant', 'Include enough context (entire leaf or affected region)', 'Capture both sides of leaves if symptoms are visible', 'Avoid including too much background'].map((tip, i) => (
|
| 52 |
+
<li key={i} className="flex items-start gap-2">
|
| 53 |
+
<CheckCircle2 className="w-4 h-4 text-primary-700 mt-0.5 flex-shrink-0" />
|
| 54 |
+
<span>{tip}</span>
|
| 55 |
+
</li>
|
| 56 |
+
))}
|
| 57 |
+
</ul>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<div className="rounded-xl p-4 border border-slate-200 bg-slate-50/60">
|
| 61 |
+
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
| 62 |
+
<span className="inline-flex items-center justify-center w-6 h-6 rounded-lg bg-white border border-slate-200 text-xs font-bold text-slate-700">
|
| 63 |
+
3
|
| 64 |
+
</span>
|
| 65 |
+
Best practices
|
| 66 |
+
</h4>
|
| 67 |
+
<ul className="space-y-2">
|
| 68 |
+
{['Take multiple photos from different angles', 'Include healthy parts for comparison if possible', 'Note the crop type and growth stage', 'Check predictions match visual symptoms'].map((tip, i) => (
|
| 69 |
+
<li key={i} className="flex items-start gap-2">
|
| 70 |
+
<CheckCircle2 className="w-4 h-4 text-primary-700 mt-0.5 flex-shrink-0" />
|
| 71 |
+
<span>{tip}</span>
|
| 72 |
+
</li>
|
| 73 |
+
))}
|
| 74 |
+
</ul>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<div className="p-4 bg-amber-50 border border-amber-200 rounded-xl">
|
| 78 |
+
<div className="flex items-start gap-3">
|
| 79 |
+
<TriangleAlert className="w-5 h-5 text-amber-700 flex-shrink-0 mt-0.5" />
|
| 80 |
+
<p className="text-sm text-amber-900">
|
| 81 |
+
<span className="font-semibold">Reminder:</span> Predictions support field decisions—confirm with local agronomists or extension services before treatment.
|
| 82 |
+
</p>
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
</div>
|
| 86 |
+
)}
|
| 87 |
+
</div>
|
| 88 |
+
)
|
| 89 |
+
}
|
components/USMap.tsx
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useState, useRef, useEffect } from 'react'
|
| 4 |
+
import { MapPin } from 'lucide-react'
|
| 5 |
+
|
| 6 |
+
interface USMapProps {
|
| 7 |
+
onLocationClick: (lat: number, lng: number) => void
|
| 8 |
+
markers?: Array<{ lat: number; lng: number; label?: string }>
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export default function USMap({ onLocationClick, markers = [] }: USMapProps) {
|
| 12 |
+
const mapRef = useRef<HTMLDivElement>(null)
|
| 13 |
+
const [isClient, setIsClient] = useState(false)
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
setIsClient(true)
|
| 17 |
+
}, [])
|
| 18 |
+
|
| 19 |
+
const handleMapClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
| 20 |
+
if (!mapRef.current) return
|
| 21 |
+
|
| 22 |
+
const rect = mapRef.current.getBoundingClientRect()
|
| 23 |
+
const x = e.clientX - rect.left
|
| 24 |
+
const y = e.clientY - rect.top
|
| 25 |
+
|
| 26 |
+
// US map bounds (approximate)
|
| 27 |
+
// Latitude: 24.396308 to 49.384358
|
| 28 |
+
// Longitude: -125.0 to -66.93457
|
| 29 |
+
const mapWidth = rect.width
|
| 30 |
+
const mapHeight = rect.height
|
| 31 |
+
|
| 32 |
+
// Convert pixel coordinates to lat/lng for US
|
| 33 |
+
const lng = -125 + (x / mapWidth) * 58 // -125 to -67
|
| 34 |
+
const lat = 49.38 - (y / mapHeight) * 25 // 49.38 to 24.39
|
| 35 |
+
|
| 36 |
+
// Clamp to US bounds
|
| 37 |
+
const clampedLat = Math.max(24.39, Math.min(49.38, lat))
|
| 38 |
+
const clampedLng = Math.max(-125, Math.min(-66.93, lng))
|
| 39 |
+
|
| 40 |
+
onLocationClick(clampedLat, clampedLng)
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const projectPoint = (lat: number, lng: number, width: number, height: number) => {
|
| 44 |
+
const x = ((lng + 125) / 58) * width
|
| 45 |
+
const y = ((49.38 - lat) / 25) * height
|
| 46 |
+
return { x, y }
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<div className="w-full h-full relative bg-gradient-to-br from-blue-50 to-green-50 rounded-lg border-2 border-slate-300 overflow-hidden">
|
| 51 |
+
{/* US Map SVG */}
|
| 52 |
+
<div
|
| 53 |
+
ref={mapRef}
|
| 54 |
+
onClick={handleMapClick}
|
| 55 |
+
className="w-full h-full cursor-crosshair relative"
|
| 56 |
+
style={{ minHeight: '500px' }}
|
| 57 |
+
>
|
| 58 |
+
<svg
|
| 59 |
+
viewBox="0 0 1000 600"
|
| 60 |
+
className="w-full h-full"
|
| 61 |
+
preserveAspectRatio="xMidYMid meet"
|
| 62 |
+
>
|
| 63 |
+
{/* Simplified US Map Outline */}
|
| 64 |
+
<path
|
| 65 |
+
d="M 100 200 L 150 180 L 200 190 L 250 200 L 300 210 L 350 220 L 400 230 L 450 240 L 500 250 L 550 260 L 600 270 L 650 280 L 700 290 L 750 300 L 800 310 L 850 320 L 900 330 L 950 340 L 900 380 L 850 400 L 800 420 L 750 440 L 700 460 L 650 480 L 600 500 L 550 520 L 500 540 L 450 550 L 400 560 L 350 570 L 300 580 L 250 580 L 200 570 L 150 550 L 100 520 L 80 480 L 70 440 L 65 400 L 70 360 L 80 320 L 90 280 L 95 240 Z"
|
| 66 |
+
fill="#e0f2fe"
|
| 67 |
+
stroke="#0284c7"
|
| 68 |
+
strokeWidth="2"
|
| 69 |
+
/>
|
| 70 |
+
|
| 71 |
+
{/* State boundaries (simplified) */}
|
| 72 |
+
<path
|
| 73 |
+
d="M 200 200 L 250 210 L 300 220 L 350 230 L 400 240 L 450 250 L 500 260 L 550 270 L 600 280 L 650 290 L 700 300 L 750 310 L 800 320 L 850 330 L 900 340 L 850 380 L 800 400 L 750 420 L 700 440 L 650 460 L 600 480 L 550 500 L 500 520 L 450 530 L 400 540 L 350 550 L 300 560 L 250 560 L 200 550 L 150 530 L 100 500 L 80 460 L 70 420 L 65 380 L 70 340 L 80 300 L 90 260 L 100 230 Z"
|
| 74 |
+
fill="none"
|
| 75 |
+
stroke="#94a3b8"
|
| 76 |
+
strokeWidth="1"
|
| 77 |
+
opacity="0.3"
|
| 78 |
+
/>
|
| 79 |
+
|
| 80 |
+
{/* Markers */}
|
| 81 |
+
{isClient &&
|
| 82 |
+
markers.map((marker, i) => {
|
| 83 |
+
const { x, y } = projectPoint(
|
| 84 |
+
marker.lat,
|
| 85 |
+
marker.lng,
|
| 86 |
+
1000,
|
| 87 |
+
600
|
| 88 |
+
)
|
| 89 |
+
return (
|
| 90 |
+
<g key={i}>
|
| 91 |
+
<circle
|
| 92 |
+
cx={x}
|
| 93 |
+
cy={y}
|
| 94 |
+
r="6"
|
| 95 |
+
fill="#ef4444"
|
| 96 |
+
stroke="white"
|
| 97 |
+
strokeWidth="2"
|
| 98 |
+
/>
|
| 99 |
+
<circle
|
| 100 |
+
cx={x}
|
| 101 |
+
cy={y}
|
| 102 |
+
r="6"
|
| 103 |
+
fill="#ef4444"
|
| 104 |
+
opacity="0.5"
|
| 105 |
+
>
|
| 106 |
+
<animate
|
| 107 |
+
attributeName="r"
|
| 108 |
+
from="6"
|
| 109 |
+
to="16"
|
| 110 |
+
dur="2s"
|
| 111 |
+
begin="0s"
|
| 112 |
+
repeatCount="indefinite"
|
| 113 |
+
/>
|
| 114 |
+
<animate
|
| 115 |
+
attributeName="opacity"
|
| 116 |
+
from="0.5"
|
| 117 |
+
to="0"
|
| 118 |
+
dur="2s"
|
| 119 |
+
begin="0s"
|
| 120 |
+
repeatCount="indefinite"
|
| 121 |
+
/>
|
| 122 |
+
</circle>
|
| 123 |
+
</g>
|
| 124 |
+
)
|
| 125 |
+
})}
|
| 126 |
+
</svg>
|
| 127 |
+
|
| 128 |
+
{/* Click instruction overlay */}
|
| 129 |
+
<div className="absolute bottom-4 left-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg z-10">
|
| 130 |
+
<p className="text-sm font-semibold flex items-center gap-2">
|
| 131 |
+
<MapPin className="w-4 h-4" />
|
| 132 |
+
Click on map to report outbreak
|
| 133 |
+
</p>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
)
|
| 138 |
+
}
|
components/USOutbreakMap.tsx
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useRef, useCallback } from 'react'
|
| 4 |
+
import dynamic from 'next/dynamic'
|
| 5 |
+
import { MapPin, AlertTriangle, X, Save, Maximize2, Minimize2 } from 'lucide-react'
|
| 6 |
+
import type { OutbreakReport } from '@/lib/outbreakReport'
|
| 7 |
+
|
| 8 |
+
// Dynamically import Google Maps component to avoid SSR issues
|
| 9 |
+
const GoogleMapComponent = dynamic(() => import('./GoogleMap'), {
|
| 10 |
+
ssr: false,
|
| 11 |
+
loading: () => (
|
| 12 |
+
<div className="flex h-full min-h-[280px] w-full items-center justify-center rounded-xl border-2 border-slate-300 bg-white sm:min-h-[400px]">
|
| 13 |
+
<div className="px-4 text-center">
|
| 14 |
+
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
| 15 |
+
<p className="text-sm font-semibold text-blue-600 sm:text-base">Loading Google Maps...</p>
|
| 16 |
+
</div>
|
| 17 |
+
</div>
|
| 18 |
+
),
|
| 19 |
+
})
|
| 20 |
+
|
| 21 |
+
interface USOutbreakMapProps {
|
| 22 |
+
reports?: OutbreakReport[]
|
| 23 |
+
onReportSubmit?: (report: OutbreakReport) => void
|
| 24 |
+
/** Reporter status for new submissions from this browser */
|
| 25 |
+
reporterVerified: boolean
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function triggerMapResize(map: google.maps.Map | null) {
|
| 29 |
+
if (!map || typeof google === 'undefined') return
|
| 30 |
+
window.setTimeout(() => {
|
| 31 |
+
google.maps.event.trigger(map, 'resize')
|
| 32 |
+
}, 120)
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function getFullscreenElement(): Element | null {
|
| 36 |
+
const doc = document as Document & {
|
| 37 |
+
webkitFullscreenElement?: Element | null
|
| 38 |
+
}
|
| 39 |
+
return document.fullscreenElement ?? doc.webkitFullscreenElement ?? null
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
async function requestElementFullscreen(el: HTMLElement): Promise<boolean> {
|
| 43 |
+
const anyEl = el as HTMLElement & {
|
| 44 |
+
webkitRequestFullscreen?: () => void
|
| 45 |
+
}
|
| 46 |
+
try {
|
| 47 |
+
if (el.requestFullscreen) {
|
| 48 |
+
await el.requestFullscreen()
|
| 49 |
+
return true
|
| 50 |
+
}
|
| 51 |
+
} catch {
|
| 52 |
+
/* try webkit */
|
| 53 |
+
}
|
| 54 |
+
try {
|
| 55 |
+
if (anyEl.webkitRequestFullscreen) {
|
| 56 |
+
anyEl.webkitRequestFullscreen()
|
| 57 |
+
return true
|
| 58 |
+
}
|
| 59 |
+
} catch {
|
| 60 |
+
/* fall through */
|
| 61 |
+
}
|
| 62 |
+
return false
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
async function exitDocumentFullscreen(): Promise<void> {
|
| 66 |
+
const doc = document as Document & { webkitExitFullscreen?: () => void }
|
| 67 |
+
try {
|
| 68 |
+
if (document.fullscreenElement && document.exitFullscreen) {
|
| 69 |
+
await document.exitFullscreen()
|
| 70 |
+
return
|
| 71 |
+
}
|
| 72 |
+
} catch {
|
| 73 |
+
/* try webkit */
|
| 74 |
+
}
|
| 75 |
+
try {
|
| 76 |
+
if (doc.webkitExitFullscreen) {
|
| 77 |
+
doc.webkitExitFullscreen()
|
| 78 |
+
}
|
| 79 |
+
} catch {
|
| 80 |
+
/* ignore */
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export default function USOutbreakMap({ reports = [], onReportSubmit, reporterVerified }: USOutbreakMapProps) {
|
| 85 |
+
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lng: number } | null>(null)
|
| 86 |
+
const [showReportForm, setShowReportForm] = useState(false)
|
| 87 |
+
const mapCardRef = useRef<HTMLDivElement>(null)
|
| 88 |
+
const mapInstanceRef = useRef<google.maps.Map | null>(null)
|
| 89 |
+
const [browserFullscreen, setBrowserFullscreen] = useState(false)
|
| 90 |
+
/** iOS / browsers without element fullscreen */
|
| 91 |
+
const [layoutFullscreen, setLayoutFullscreen] = useState(false)
|
| 92 |
+
|
| 93 |
+
const expanded = browserFullscreen || layoutFullscreen
|
| 94 |
+
|
| 95 |
+
const handleMapReady = useCallback((map: google.maps.Map) => {
|
| 96 |
+
mapInstanceRef.current = map
|
| 97 |
+
}, [])
|
| 98 |
+
|
| 99 |
+
useEffect(() => {
|
| 100 |
+
const syncFs = () => {
|
| 101 |
+
const fsEl = getFullscreenElement()
|
| 102 |
+
setBrowserFullscreen(fsEl === mapCardRef.current)
|
| 103 |
+
}
|
| 104 |
+
syncFs()
|
| 105 |
+
document.addEventListener('fullscreenchange', syncFs)
|
| 106 |
+
document.addEventListener('webkitfullscreenchange', syncFs)
|
| 107 |
+
return () => {
|
| 108 |
+
document.removeEventListener('fullscreenchange', syncFs)
|
| 109 |
+
document.removeEventListener('webkitfullscreenchange', syncFs)
|
| 110 |
+
}
|
| 111 |
+
}, [])
|
| 112 |
+
|
| 113 |
+
useEffect(() => {
|
| 114 |
+
triggerMapResize(mapInstanceRef.current)
|
| 115 |
+
}, [expanded])
|
| 116 |
+
|
| 117 |
+
useEffect(() => {
|
| 118 |
+
const onResize = () => triggerMapResize(mapInstanceRef.current)
|
| 119 |
+
window.addEventListener('orientationchange', onResize)
|
| 120 |
+
window.addEventListener('resize', onResize)
|
| 121 |
+
return () => {
|
| 122 |
+
window.removeEventListener('orientationchange', onResize)
|
| 123 |
+
window.removeEventListener('resize', onResize)
|
| 124 |
+
}
|
| 125 |
+
}, [])
|
| 126 |
+
|
| 127 |
+
const exitAllFullscreen = useCallback(async () => {
|
| 128 |
+
if (getFullscreenElement()) {
|
| 129 |
+
await exitDocumentFullscreen()
|
| 130 |
+
}
|
| 131 |
+
setLayoutFullscreen(false)
|
| 132 |
+
}, [])
|
| 133 |
+
|
| 134 |
+
const toggleFullscreen = useCallback(async () => {
|
| 135 |
+
const el = mapCardRef.current
|
| 136 |
+
if (!el) return
|
| 137 |
+
|
| 138 |
+
if (expanded) {
|
| 139 |
+
await exitAllFullscreen()
|
| 140 |
+
triggerMapResize(mapInstanceRef.current)
|
| 141 |
+
return
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
const enteredFs = await requestElementFullscreen(el)
|
| 145 |
+
if (enteredFs) {
|
| 146 |
+
triggerMapResize(mapInstanceRef.current)
|
| 147 |
+
return
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
setLayoutFullscreen(true)
|
| 151 |
+
triggerMapResize(mapInstanceRef.current)
|
| 152 |
+
}, [expanded, exitAllFullscreen])
|
| 153 |
+
|
| 154 |
+
useEffect(() => {
|
| 155 |
+
if (!layoutFullscreen && !browserFullscreen) return
|
| 156 |
+
const prev = document.body.style.overflow
|
| 157 |
+
document.body.style.overflow = 'hidden'
|
| 158 |
+
return () => {
|
| 159 |
+
document.body.style.overflow = prev
|
| 160 |
+
}
|
| 161 |
+
}, [layoutFullscreen, browserFullscreen])
|
| 162 |
+
|
| 163 |
+
useEffect(() => {
|
| 164 |
+
if (!expanded) return
|
| 165 |
+
const onKey = (e: KeyboardEvent) => {
|
| 166 |
+
if (e.key === 'Escape') void exitAllFullscreen()
|
| 167 |
+
}
|
| 168 |
+
window.addEventListener('keydown', onKey)
|
| 169 |
+
return () => window.removeEventListener('keydown', onKey)
|
| 170 |
+
}, [expanded, exitAllFullscreen])
|
| 171 |
+
|
| 172 |
+
useEffect(() => {
|
| 173 |
+
return () => {
|
| 174 |
+
document.body.style.removeProperty('overflow')
|
| 175 |
+
void exitDocumentFullscreen()
|
| 176 |
+
}
|
| 177 |
+
}, [])
|
| 178 |
+
|
| 179 |
+
// Ensure modal closes on mount/unmount to prevent stuck overlays
|
| 180 |
+
useEffect(() => {
|
| 181 |
+
return () => {
|
| 182 |
+
setShowReportForm(false)
|
| 183 |
+
setSelectedLocation(null)
|
| 184 |
+
}
|
| 185 |
+
}, [])
|
| 186 |
+
|
| 187 |
+
const [formData, setFormData] = useState({
|
| 188 |
+
crop: '',
|
| 189 |
+
disease: '',
|
| 190 |
+
severity: 'medium' as 'low' | 'medium' | 'high',
|
| 191 |
+
description: '',
|
| 192 |
+
})
|
| 193 |
+
|
| 194 |
+
const handleMapClick = (lat: number, lng: number) => {
|
| 195 |
+
// Restrict to US bounds
|
| 196 |
+
const clampedLat = Math.max(24.39, Math.min(49.38, lat))
|
| 197 |
+
const clampedLng = Math.max(-125, Math.min(-66.93, lng))
|
| 198 |
+
|
| 199 |
+
setSelectedLocation({ lat: clampedLat, lng: clampedLng })
|
| 200 |
+
setShowReportForm(true)
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
const handleSubmitReport = () => {
|
| 204 |
+
if (!selectedLocation || !formData.crop || !formData.disease) {
|
| 205 |
+
alert('Please fill in all required fields')
|
| 206 |
+
return
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
const newReport: OutbreakReport = {
|
| 210 |
+
id: Date.now().toString(),
|
| 211 |
+
lat: selectedLocation.lat,
|
| 212 |
+
lng: selectedLocation.lng,
|
| 213 |
+
crop: formData.crop,
|
| 214 |
+
disease: formData.disease,
|
| 215 |
+
severity: formData.severity,
|
| 216 |
+
date: new Date().toISOString(),
|
| 217 |
+
description: formData.description,
|
| 218 |
+
reporterVerified,
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
if (onReportSubmit) {
|
| 222 |
+
onReportSubmit(newReport)
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
setShowReportForm(false)
|
| 226 |
+
setSelectedLocation(null)
|
| 227 |
+
setFormData({
|
| 228 |
+
crop: '',
|
| 229 |
+
disease: '',
|
| 230 |
+
severity: 'medium',
|
| 231 |
+
description: '',
|
| 232 |
+
})
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
return (
|
| 236 |
+
<div className="relative w-full">
|
| 237 |
+
{/* Map card: explicit height so the map fills the area (no aspect-ratio gap) */}
|
| 238 |
+
<div
|
| 239 |
+
ref={mapCardRef}
|
| 240 |
+
className={`flex w-full flex-col overflow-hidden rounded-xl border-2 border-slate-300 bg-white shadow-lg ${
|
| 241 |
+
layoutFullscreen
|
| 242 |
+
? 'fixed inset-0 z-[5000] h-[100dvh] max-h-[100dvh] min-h-0 w-full max-w-none rounded-none border-slate-300 pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]'
|
| 243 |
+
: ''
|
| 244 |
+
} ${expanded ? 'h-full min-h-0' : ''}`}
|
| 245 |
+
>
|
| 246 |
+
<div
|
| 247 |
+
className={`relative flex w-full min-h-0 flex-col ${
|
| 248 |
+
expanded ? 'h-full min-h-0 flex-1' : ''
|
| 249 |
+
}`}
|
| 250 |
+
>
|
| 251 |
+
<div
|
| 252 |
+
className={`relative min-h-0 w-full overflow-hidden ${
|
| 253 |
+
expanded
|
| 254 |
+
? 'min-h-0 flex-1'
|
| 255 |
+
: 'h-[min(52dvh,560px)] min-h-[280px] sm:min-h-[420px]'
|
| 256 |
+
}`}
|
| 257 |
+
>
|
| 258 |
+
<GoogleMapComponent
|
| 259 |
+
reports={reports}
|
| 260 |
+
onMapClick={handleMapClick}
|
| 261 |
+
center={{ lat: 39.8283, lng: -98.5795 }}
|
| 262 |
+
zoom={4}
|
| 263 |
+
showMapClickHint={false}
|
| 264 |
+
fullscreenControl
|
| 265 |
+
onMapReady={handleMapReady}
|
| 266 |
+
/>
|
| 267 |
+
|
| 268 |
+
<div className="pointer-events-auto absolute right-2 top-2 z-[1001] flex gap-1 sm:right-3 sm:top-3">
|
| 269 |
+
<button
|
| 270 |
+
type="button"
|
| 271 |
+
onClick={() => void toggleFullscreen()}
|
| 272 |
+
className="touch-manipulation rounded-lg border border-slate-200 bg-white/95 px-2.5 py-2 text-slate-800 shadow-md backdrop-blur-sm transition hover:bg-white sm:px-3"
|
| 273 |
+
aria-label={expanded ? 'Exit fullscreen map' : 'Fullscreen map'}
|
| 274 |
+
title={expanded ? 'Exit fullscreen' : 'Fullscreen'}
|
| 275 |
+
>
|
| 276 |
+
{expanded ? (
|
| 277 |
+
<Minimize2 className="h-5 w-5 sm:h-5 sm:w-5" />
|
| 278 |
+
) : (
|
| 279 |
+
<Maximize2 className="h-5 w-5 sm:h-5 sm:w-5" />
|
| 280 |
+
)}
|
| 281 |
+
</button>
|
| 282 |
+
</div>
|
| 283 |
+
|
| 284 |
+
{selectedLocation && !showReportForm && (
|
| 285 |
+
<div className="pointer-events-none absolute left-2 top-12 z-[1000] max-w-[calc(100%-4rem)] rounded-lg border-2 border-blue-200 bg-white p-2 shadow-xl sm:left-3 sm:top-14 sm:p-3">
|
| 286 |
+
<p className="mb-0.5 text-[11px] font-bold text-slate-900 sm:text-xs">📍 Selected</p>
|
| 287 |
+
<p className="break-all font-mono text-[10px] text-slate-600 sm:text-xs">
|
| 288 |
+
{selectedLocation.lat.toFixed(4)}, {selectedLocation.lng.toFixed(4)}
|
| 289 |
+
</p>
|
| 290 |
+
</div>
|
| 291 |
+
)}
|
| 292 |
+
</div>
|
| 293 |
+
|
| 294 |
+
<div className="flex shrink-0 items-center gap-2 border-t border-slate-200 bg-slate-50 px-3 py-2.5 sm:px-4 sm:py-3">
|
| 295 |
+
<MapPin className="h-4 w-4 shrink-0 text-blue-700" />
|
| 296 |
+
<p className="text-xs font-semibold leading-snug text-slate-800 sm:text-sm">
|
| 297 |
+
Tap or click the map to report an outbreak
|
| 298 |
+
</p>
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
|
| 303 |
+
{/* Report Form Modal — no AnimatePresence exit on full-screen layer (opacity-0 still captures clicks). */}
|
| 304 |
+
{showReportForm && (
|
| 305 |
+
<div className="fixed inset-0 z-[6000] flex items-end justify-center sm:items-center sm:p-4">
|
| 306 |
+
<div
|
| 307 |
+
role="presentation"
|
| 308 |
+
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
| 309 |
+
onClick={() => {
|
| 310 |
+
setShowReportForm(false)
|
| 311 |
+
setSelectedLocation(null)
|
| 312 |
+
}}
|
| 313 |
+
/>
|
| 314 |
+
<div
|
| 315 |
+
onClick={(e) => e.stopPropagation()}
|
| 316 |
+
className="relative z-10 max-h-[90dvh] w-full max-w-md overflow-y-auto overscroll-contain rounded-t-2xl border border-slate-200 bg-white p-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] shadow-2xl sm:rounded-2xl sm:p-6 sm:pb-6"
|
| 317 |
+
>
|
| 318 |
+
<div className="flex items-center justify-between mb-6">
|
| 319 |
+
<h2 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
|
| 320 |
+
<div className="p-2 bg-orange-100 rounded-lg">
|
| 321 |
+
<AlertTriangle className="w-6 h-6 text-orange-600" />
|
| 322 |
+
</div>
|
| 323 |
+
Report Outbreak
|
| 324 |
+
</h2>
|
| 325 |
+
<button
|
| 326 |
+
onClick={() => {
|
| 327 |
+
setShowReportForm(false)
|
| 328 |
+
setSelectedLocation(null)
|
| 329 |
+
}}
|
| 330 |
+
className="text-slate-400 hover:text-slate-600 transition-colors p-1 hover:bg-slate-100 rounded-lg"
|
| 331 |
+
>
|
| 332 |
+
<X className="w-5 h-5" />
|
| 333 |
+
</button>
|
| 334 |
+
</div>
|
| 335 |
+
|
| 336 |
+
{selectedLocation && (
|
| 337 |
+
<div className="mb-4 p-3 bg-blue-50 rounded-lg border border-blue-200">
|
| 338 |
+
<p className="text-xs text-blue-700 font-semibold mb-1">Location</p>
|
| 339 |
+
<p className="text-sm text-blue-900 font-mono">
|
| 340 |
+
{selectedLocation.lat.toFixed(4)}, {selectedLocation.lng.toFixed(4)}
|
| 341 |
+
</p>
|
| 342 |
+
</div>
|
| 343 |
+
)}
|
| 344 |
+
|
| 345 |
+
<div className="space-y-4">
|
| 346 |
+
<div>
|
| 347 |
+
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
| 348 |
+
Crop Type <span className="text-red-500">*</span>
|
| 349 |
+
</label>
|
| 350 |
+
<input
|
| 351 |
+
type="text"
|
| 352 |
+
value={formData.crop}
|
| 353 |
+
onChange={(e) => setFormData({ ...formData, crop: e.target.value })}
|
| 354 |
+
placeholder="e.g., Corn, Wheat, Rice"
|
| 355 |
+
className="w-full px-4 py-2.5 border-2 border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all"
|
| 356 |
+
/>
|
| 357 |
+
</div>
|
| 358 |
+
|
| 359 |
+
<div>
|
| 360 |
+
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
| 361 |
+
Disease Name <span className="text-red-500">*</span>
|
| 362 |
+
</label>
|
| 363 |
+
<input
|
| 364 |
+
type="text"
|
| 365 |
+
value={formData.disease}
|
| 366 |
+
onChange={(e) => setFormData({ ...formData, disease: e.target.value })}
|
| 367 |
+
placeholder="e.g., Rust, Blight, Mosaic"
|
| 368 |
+
className="w-full px-4 py-2.5 border-2 border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all"
|
| 369 |
+
/>
|
| 370 |
+
</div>
|
| 371 |
+
|
| 372 |
+
<div>
|
| 373 |
+
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
| 374 |
+
Severity <span className="text-red-500">*</span>
|
| 375 |
+
</label>
|
| 376 |
+
<select
|
| 377 |
+
value={formData.severity}
|
| 378 |
+
onChange={(e) =>
|
| 379 |
+
setFormData({
|
| 380 |
+
...formData,
|
| 381 |
+
severity: e.target.value as 'low' | 'medium' | 'high',
|
| 382 |
+
})
|
| 383 |
+
}
|
| 384 |
+
className="w-full px-4 py-2.5 border-2 border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all bg-white"
|
| 385 |
+
>
|
| 386 |
+
<option value="low">🟡 Low</option>
|
| 387 |
+
<option value="medium">🟠 Medium</option>
|
| 388 |
+
<option value="high">🔴 High</option>
|
| 389 |
+
</select>
|
| 390 |
+
</div>
|
| 391 |
+
|
| 392 |
+
<div>
|
| 393 |
+
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
| 394 |
+
Description
|
| 395 |
+
</label>
|
| 396 |
+
<textarea
|
| 397 |
+
value={formData.description}
|
| 398 |
+
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
| 399 |
+
placeholder="Additional details about the outbreak..."
|
| 400 |
+
rows={3}
|
| 401 |
+
className="w-full px-4 py-2.5 border-2 border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all resize-none"
|
| 402 |
+
/>
|
| 403 |
+
</div>
|
| 404 |
+
|
| 405 |
+
<button
|
| 406 |
+
onClick={handleSubmitReport}
|
| 407 |
+
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-semibold py-3 px-6 rounded-lg transition-all flex items-center justify-center gap-2 shadow-lg hover:shadow-xl transform hover:scale-[1.02]"
|
| 408 |
+
>
|
| 409 |
+
<Save className="w-5 h-5" />
|
| 410 |
+
Submit Report
|
| 411 |
+
</button>
|
| 412 |
+
</div>
|
| 413 |
+
</div>
|
| 414 |
+
</div>
|
| 415 |
+
)}
|
| 416 |
+
</div>
|
| 417 |
+
)
|
| 418 |
+
}
|
components/ui/theme-provider.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import * as React from 'react'
|
| 4 |
+
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
| 5 |
+
|
| 6 |
+
export function ThemeProvider({
|
| 7 |
+
children,
|
| 8 |
+
...props
|
| 9 |
+
}: React.ComponentProps<typeof NextThemesProvider>) {
|
| 10 |
+
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
| 11 |
+
}
|
components/ui/world-map.tsx
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useRef } from "react";
|
| 4 |
+
import { motion } from "framer-motion";
|
| 5 |
+
import DottedMap from "dotted-map";
|
| 6 |
+
import Image from "next/image";
|
| 7 |
+
import { useTheme } from "next-themes";
|
| 8 |
+
|
| 9 |
+
interface MapProps {
|
| 10 |
+
dots?: Array<{
|
| 11 |
+
start: { lat: number; lng: number; label?: string };
|
| 12 |
+
end: { lat: number; lng: number; label?: string };
|
| 13 |
+
}>;
|
| 14 |
+
lineColor?: string;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function WorldMap({
|
| 18 |
+
dots = [],
|
| 19 |
+
lineColor = "#0ea5e9",
|
| 20 |
+
}: MapProps) {
|
| 21 |
+
const svgRef = useRef<SVGSVGElement>(null);
|
| 22 |
+
const map = new DottedMap({ height: 100, grid: "diagonal" });
|
| 23 |
+
|
| 24 |
+
const { theme } = useTheme();
|
| 25 |
+
|
| 26 |
+
const svgMap = map.getSVG({
|
| 27 |
+
radius: 0.22,
|
| 28 |
+
color: theme === "dark" ? "#FFFFFF40" : "#00000040",
|
| 29 |
+
shape: "circle",
|
| 30 |
+
backgroundColor: theme === "dark" ? "black" : "white",
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
const projectPoint = (lat: number, lng: number) => {
|
| 34 |
+
const x = (lng + 180) * (800 / 360);
|
| 35 |
+
const y = (90 - lat) * (400 / 180);
|
| 36 |
+
return { x, y };
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const createCurvedPath = (
|
| 40 |
+
start: { x: number; y: number },
|
| 41 |
+
end: { x: number; y: number }
|
| 42 |
+
) => {
|
| 43 |
+
const midX = (start.x + end.x) / 2;
|
| 44 |
+
const midY = Math.min(start.y, end.y) - 50;
|
| 45 |
+
return `M ${start.x} ${start.y} Q ${midX} ${midY} ${end.x} ${end.y}`;
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<div className="w-full aspect-[2/1] dark:bg-black bg-white rounded-lg relative font-sans">
|
| 50 |
+
<Image
|
| 51 |
+
src={`data:image/svg+xml;utf8,${encodeURIComponent(svgMap)}`}
|
| 52 |
+
className="h-full w-full [mask-image:linear-gradient(to_bottom,transparent,white_10%,white_90%,transparent)] pointer-events-none select-none"
|
| 53 |
+
alt="world map"
|
| 54 |
+
height="495"
|
| 55 |
+
width="1056"
|
| 56 |
+
draggable={false}
|
| 57 |
+
/>
|
| 58 |
+
<svg
|
| 59 |
+
ref={svgRef}
|
| 60 |
+
viewBox="0 0 800 400"
|
| 61 |
+
className="w-full h-full absolute inset-0 pointer-events-none select-none"
|
| 62 |
+
>
|
| 63 |
+
{dots.map((dot, i) => {
|
| 64 |
+
const startPoint = projectPoint(dot.start.lat, dot.start.lng);
|
| 65 |
+
const endPoint = projectPoint(dot.end.lat, dot.end.lng);
|
| 66 |
+
return (
|
| 67 |
+
<g key={`path-group-${i}`}>
|
| 68 |
+
<motion.path
|
| 69 |
+
d={createCurvedPath(startPoint, endPoint)}
|
| 70 |
+
fill="none"
|
| 71 |
+
stroke="url(#path-gradient)"
|
| 72 |
+
strokeWidth="1"
|
| 73 |
+
initial={{
|
| 74 |
+
pathLength: 0,
|
| 75 |
+
}}
|
| 76 |
+
animate={{
|
| 77 |
+
pathLength: 1,
|
| 78 |
+
}}
|
| 79 |
+
transition={{
|
| 80 |
+
duration: 1,
|
| 81 |
+
delay: 0.5 * i,
|
| 82 |
+
ease: "easeOut",
|
| 83 |
+
}}
|
| 84 |
+
key={`start-upper-${i}`}
|
| 85 |
+
></motion.path>
|
| 86 |
+
</g>
|
| 87 |
+
);
|
| 88 |
+
})}
|
| 89 |
+
|
| 90 |
+
<defs>
|
| 91 |
+
<linearGradient id="path-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
| 92 |
+
<stop offset="0%" stopColor="white" stopOpacity="0" />
|
| 93 |
+
<stop offset="5%" stopColor={lineColor} stopOpacity="1" />
|
| 94 |
+
<stop offset="95%" stopColor={lineColor} stopOpacity="1" />
|
| 95 |
+
<stop offset="100%" stopColor="white" stopOpacity="0" />
|
| 96 |
+
</linearGradient>
|
| 97 |
+
</defs>
|
| 98 |
+
|
| 99 |
+
{dots.map((dot, i) => (
|
| 100 |
+
<g key={`points-group-${i}`}>
|
| 101 |
+
<g key={`start-${i}`}>
|
| 102 |
+
<circle
|
| 103 |
+
cx={projectPoint(dot.start.lat, dot.start.lng).x}
|
| 104 |
+
cy={projectPoint(dot.start.lat, dot.start.lng).y}
|
| 105 |
+
r="2"
|
| 106 |
+
fill={lineColor}
|
| 107 |
+
/>
|
| 108 |
+
<circle
|
| 109 |
+
cx={projectPoint(dot.start.lat, dot.start.lng).x}
|
| 110 |
+
cy={projectPoint(dot.start.lat, dot.start.lng).y}
|
| 111 |
+
r="2"
|
| 112 |
+
fill={lineColor}
|
| 113 |
+
opacity="0.5"
|
| 114 |
+
>
|
| 115 |
+
<animate
|
| 116 |
+
attributeName="r"
|
| 117 |
+
from="2"
|
| 118 |
+
to="8"
|
| 119 |
+
dur="1.5s"
|
| 120 |
+
begin="0s"
|
| 121 |
+
repeatCount="indefinite"
|
| 122 |
+
/>
|
| 123 |
+
<animate
|
| 124 |
+
attributeName="opacity"
|
| 125 |
+
from="0.5"
|
| 126 |
+
to="0"
|
| 127 |
+
dur="1.5s"
|
| 128 |
+
begin="0s"
|
| 129 |
+
repeatCount="indefinite"
|
| 130 |
+
/>
|
| 131 |
+
</circle>
|
| 132 |
+
</g>
|
| 133 |
+
<g key={`end-${i}`}>
|
| 134 |
+
<circle
|
| 135 |
+
cx={projectPoint(dot.end.lat, dot.end.lng).x}
|
| 136 |
+
cy={projectPoint(dot.end.lat, dot.end.lng).y}
|
| 137 |
+
r="2"
|
| 138 |
+
fill={lineColor}
|
| 139 |
+
/>
|
| 140 |
+
<circle
|
| 141 |
+
cx={projectPoint(dot.end.lat, dot.end.lng).x}
|
| 142 |
+
cy={projectPoint(dot.end.lat, dot.end.lng).y}
|
| 143 |
+
r="2"
|
| 144 |
+
fill={lineColor}
|
| 145 |
+
opacity="0.5"
|
| 146 |
+
>
|
| 147 |
+
<animate
|
| 148 |
+
attributeName="r"
|
| 149 |
+
from="2"
|
| 150 |
+
to="8"
|
| 151 |
+
dur="1.5s"
|
| 152 |
+
begin="0s"
|
| 153 |
+
repeatCount="indefinite"
|
| 154 |
+
/>
|
| 155 |
+
<animate
|
| 156 |
+
attributeName="opacity"
|
| 157 |
+
from="0.5"
|
| 158 |
+
to="0"
|
| 159 |
+
dur="1.5s"
|
| 160 |
+
begin="0s"
|
| 161 |
+
repeatCount="indefinite"
|
| 162 |
+
/>
|
| 163 |
+
</circle>
|
| 164 |
+
</g>
|
| 165 |
+
</g>
|
| 166 |
+
))}
|
| 167 |
+
</svg>
|
| 168 |
+
</div>
|
| 169 |
+
);
|
| 170 |
+
}
|
cropintel_model_metrics_overall.csv
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
crop,version,accuracy,precision,recall,f1_score
|
| 2 |
+
corn,v1_20260118_144945,0.9476190476190476,0.9472791410761335,0.9476190476190476,0.9473238418683883
|
| 3 |
+
rice,v1_20260118_161225,0.9922480620155039,0.9923664191106051,0.9922480620155039,0.9922153632970051
|
| 4 |
+
soybean,v1_20260118_225345,0.9661016949152542,0.9693502824858756,0.9661016949152542,0.9613091759205145
|
| 5 |
+
wheat,v1_20260118_234239,0.9293893129770993,0.9321654830534337,0.9293893129770993,0.9291718787641892
|
cropintel_model_metrics_overall.png
ADDED
|
cropintel_model_metrics_per_class.csv
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
crop,version,class_name,support,precision,recall,f1_score
|
| 2 |
+
corn,v1_20260118_144945,Blight,115,0.9145299145299145,0.9304347826086956,0.9224137931034483
|
| 3 |
+
corn,v1_20260118_144945,Common Rust,131,0.9624060150375939,0.9770992366412213,0.9696969696969697
|
| 4 |
+
corn,v1_20260118_144945,Gray Leaf Spot,57,0.8703703703703703,0.8245614035087719,0.8468468468468469
|
| 5 |
+
corn,v1_20260118_144945,Healthy,117,1.0,0.9914529914529915,0.9957081545064378
|
| 6 |
+
rice,v1_20260118_161225,Bacterial Leaf Blight,64,0.9846153846153847,1.0,0.9922480620155039
|
| 7 |
+
rice,v1_20260118_161225,Brown Spot,65,0.9848484848484849,1.0,0.9923664122137404
|
| 8 |
+
rice,v1_20260118_161225,Healthy,66,1.0,1.0,1.0
|
| 9 |
+
rice,v1_20260118_161225,Rice Blast,63,1.0,0.9682539682539683,0.9838709677419355
|
| 10 |
+
soybean,v1_20260118_225345,D Mossaic Virus,2,1.0,0.5,0.6666666666666666
|
| 11 |
+
soybean,v1_20260118_225345,D septoria,2,1.0,0.5,0.6666666666666666
|
| 12 |
+
soybean,v1_20260118_225345,Southern blight,7,0.875,1.0,0.9333333333333333
|
| 13 |
+
soybean,v1_20260118_225345,Sudden Death Syndrone,11,1.0,1.0,1.0
|
| 14 |
+
soybean,v1_20260118_225345,Yellow Mosaic,11,1.0,1.0,1.0
|
| 15 |
+
soybean,v1_20260118_225345,bacterial_blight,5,1.0,1.0,1.0
|
| 16 |
+
soybean,v1_20260118_225345,ferrugen,7,1.0,1.0,1.0
|
| 17 |
+
soybean,v1_20260118_225345,powdery_mildew,14,0.9333333333333333,1.0,0.9655172413793104
|
| 18 |
+
wheat,v1_20260118_234239,Healthy,100,0.9433962264150944,1.0,0.970873786407767
|
| 19 |
+
wheat,v1_20260118_234239,Leaf Rust,127,0.9272727272727272,0.8031496062992126,0.8607594936708861
|
| 20 |
+
wheat,v1_20260118_234239,Powdery Mildew,108,0.981651376146789,0.9907407407407407,0.9861751152073732
|
| 21 |
+
wheat,v1_20260118_234239,Stem Rust,58,0.7121212121212122,0.8103448275862069,0.7580645161290323
|
| 22 |
+
wheat,v1_20260118_234239,Stripe (Yellow) Rust,131,0.9849624060150376,1.0,0.9924242424242424
|
docker-compose.ml.yml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CropIntel ML stack: reproducible Python + TensorFlow without touching host Python.
|
| 2 |
+
# Data and trained weights live in mounted volumes (still gitignored on the host).
|
| 3 |
+
#
|
| 4 |
+
# Quick smoke test (no Kaggle):
|
| 5 |
+
# docker compose -f docker-compose.ml.yml build
|
| 6 |
+
# docker compose -f docker-compose.ml.yml run --rm ml \
|
| 7 |
+
# python -m ml.scripts.create_synthetic_dataset --crop corn --force
|
| 8 |
+
# docker compose -f docker-compose.ml.yml run --rm ml \
|
| 9 |
+
# python -m ml.training.train_crop --crop corn --epochs 2 --no-fine-tune
|
| 10 |
+
#
|
| 11 |
+
# With Kaggle (mount token; dataset terms must be accepted on the website):
|
| 12 |
+
# docker compose -f docker-compose.ml.yml run --rm -v "$HOME/.kaggle:/root/.kaggle:ro" ml \
|
| 13 |
+
# python -m ml.scripts.download_datasets
|
| 14 |
+
|
| 15 |
+
services:
|
| 16 |
+
ml:
|
| 17 |
+
build:
|
| 18 |
+
context: .
|
| 19 |
+
dockerfile: Dockerfile.ml
|
| 20 |
+
image: cropintel-ml:local
|
| 21 |
+
working_dir: /app
|
| 22 |
+
environment:
|
| 23 |
+
PYTHONPATH: /app
|
| 24 |
+
volumes:
|
| 25 |
+
- ./ml/data:/app/ml/data
|
| 26 |
+
- ./ml/models:/app/ml/models
|
| 27 |
+
# Optional Kaggle token (only if downloading inside Docker):
|
| 28 |
+
# docker compose -f docker-compose.ml.yml run --rm -v ~/.kaggle:/root/.kaggle:ro ml ...
|
docker-compose.prod.yml
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CropIntel production stack (single VPS).
|
| 2 |
+
#
|
| 3 |
+
# Differences from docker-compose.yml (dev):
|
| 4 |
+
# - no repo bind-mount: the image is the artifact (next build baked in)
|
| 5 |
+
# - supervisord runs both the Next.js server and the Python inference service
|
| 6 |
+
# - restart policy, healthcheck, and log rotation for unattended operation
|
| 7 |
+
#
|
| 8 |
+
# Usage:
|
| 9 |
+
# export CROPINTEL_MODELS_URL='https://github.com/rakshithj09/CropIntel/releases/download/v1/cropintel-models-mobile.zip'
|
| 10 |
+
# docker compose -f docker-compose.prod.yml up -d --build
|
| 11 |
+
#
|
| 12 |
+
# See docs/DEPLOYMENT.md for the full runbook (reverse proxy, backups, promotion).
|
| 13 |
+
|
| 14 |
+
services:
|
| 15 |
+
app:
|
| 16 |
+
build:
|
| 17 |
+
context: .
|
| 18 |
+
args:
|
| 19 |
+
# NEXT_PUBLIC_* is inlined at build time, so the outbreak map needs the
|
| 20 |
+
# key baked into the image — not just present in `environment` at runtime.
|
| 21 |
+
- NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=${NEXT_PUBLIC_GOOGLE_MAPS_API_KEY:-}
|
| 22 |
+
ports:
|
| 23 |
+
- "3050:3050"
|
| 24 |
+
environment:
|
| 25 |
+
- CROPINTEL_MODELS_URL=${CROPINTEL_MODELS_URL:-https://github.com/rakshithj09/CropIntel/releases/download/v1/cropintel-models-mobile.zip}
|
| 26 |
+
- NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=${NEXT_PUBLIC_GOOGLE_MAPS_API_KEY:-}
|
| 27 |
+
- CROPINTEL_ADMIN_TOKEN=${CROPINTEL_ADMIN_TOKEN:-}
|
| 28 |
+
volumes:
|
| 29 |
+
- ./ml/models:/app/ml/models
|
| 30 |
+
- ./data:/app/data
|
| 31 |
+
restart: unless-stopped
|
| 32 |
+
healthcheck:
|
| 33 |
+
test: ["CMD", "curl", "-fsS", "http://localhost:3050/api/health"]
|
| 34 |
+
interval: 30s
|
| 35 |
+
timeout: 10s
|
| 36 |
+
retries: 3
|
| 37 |
+
start_period: 90s
|
| 38 |
+
logging:
|
| 39 |
+
driver: json-file
|
| 40 |
+
options:
|
| 41 |
+
max-size: "20m"
|
| 42 |
+
max-file: "5"
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CropIntel local stack — no Kaggle if you set CROPINTEL_MODELS_URL to a .zip of ml/models/
|
| 2 |
+
#
|
| 3 |
+
# Usage:
|
| 4 |
+
# export CROPINTEL_MODELS_URL='https://github.com/ORG/REPO/releases/download/v1/cropintel-models.zip'
|
| 5 |
+
# docker compose up --build
|
| 6 |
+
#
|
| 7 |
+
# Or bind-mount models you already have on the host:
|
| 8 |
+
# docker compose run --rm -v ./ml/models:/app/ml/models app npm run dev
|
| 9 |
+
|
| 10 |
+
services:
|
| 11 |
+
app:
|
| 12 |
+
build: .
|
| 13 |
+
ports:
|
| 14 |
+
- "3050:3050"
|
| 15 |
+
environment:
|
| 16 |
+
- CROPINTEL_MODELS_URL=${CROPINTEL_MODELS_URL:-}
|
| 17 |
+
- NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=${NEXT_PUBLIC_GOOGLE_MAPS_API_KEY:-}
|
| 18 |
+
volumes:
|
| 19 |
+
- ./ml/models:/app/ml/models
|
| 20 |
+
- ./:/app
|
| 21 |
+
- /app/node_modules
|
| 22 |
+
- /app/.next
|
docker/entrypoint.sh
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/sh
|
| 2 |
+
set -e
|
| 3 |
+
cd /app
|
| 4 |
+
|
| 5 |
+
if [ -n "${CROPINTEL_MODELS_URL}" ]; then
|
| 6 |
+
if [ ! -f ml/models/.cropintel-fetch-ok ]; then
|
| 7 |
+
echo "Fetching pre-built models from CROPINTEL_MODELS_URL…"
|
| 8 |
+
python3 -m ml.scripts.fetch_models --url "${CROPINTEL_MODELS_URL}"
|
| 9 |
+
touch ml/models/.cropintel-fetch-ok
|
| 10 |
+
else
|
| 11 |
+
echo "Models already present (ml/models/.cropintel-fetch-ok); skip fetch."
|
| 12 |
+
fi
|
| 13 |
+
else
|
| 14 |
+
echo "Note: CROPINTEL_MODELS_URL not set. Place models under ml/models/ or predictions will fail."
|
| 15 |
+
fi
|
| 16 |
+
|
| 17 |
+
exec "$@"
|
docker/supervisord.conf
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[supervisord]
|
| 2 |
+
nodaemon=true
|
| 3 |
+
logfile=/dev/null
|
| 4 |
+
logfile_maxbytes=0
|
| 5 |
+
pidfile=/tmp/supervisord.pid
|
| 6 |
+
|
| 7 |
+
[program:inference]
|
| 8 |
+
command=python3 -m uvicorn ml.serve.inference_app:app --host %(ENV_INFERENCE_BIND_HOST)s --port 8000
|
| 9 |
+
directory=/app
|
| 10 |
+
autorestart=true
|
| 11 |
+
startretries=10
|
| 12 |
+
stopsignal=TERM
|
| 13 |
+
stdout_logfile=/dev/stdout
|
| 14 |
+
stdout_logfile_maxbytes=0
|
| 15 |
+
stderr_logfile=/dev/stderr
|
| 16 |
+
stderr_logfile_maxbytes=0
|
| 17 |
+
environment=CROPINTEL_BACKEND="tflite",CROPINTEL_PREDICTION_LOG="/app/data/predictions.jsonl"
|
| 18 |
+
|
| 19 |
+
[program:web]
|
| 20 |
+
command=node node_modules/.bin/next start -H 0.0.0.0 -p 3050
|
| 21 |
+
directory=/app
|
| 22 |
+
autorestart=true
|
| 23 |
+
startretries=10
|
| 24 |
+
stopsignal=TERM
|
| 25 |
+
stdout_logfile=/dev/stdout
|
| 26 |
+
stdout_logfile_maxbytes=0
|
| 27 |
+
stderr_logfile=/dev/stderr
|
| 28 |
+
stderr_logfile_maxbytes=0
|
| 29 |
+
environment=NODE_ENV="production",INFERENCE_URL="http://127.0.0.1:8000"
|
docs/CURSOR_AND_COLLABORATORS.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Notes for Cursor / new contributors
|
| 2 |
+
|
| 3 |
+
- **Kaggle is optional.** Cloning the repo does not include `ml/models/` (gitignored). Without models, `/api/predict` fails.
|
| 4 |
+
- **Fix:** Set `CROPINTEL_MODELS_URL` to a direct HTTPS link to a `.zip` built from `ml/models/` (see `python3 -m ml.scripts.package_models`), then run `python3 -m ml.scripts.fetch_models`, or use **Docker Compose** with that env var (see root `README.md`).
|
| 5 |
+
- **Training / accuracy:** Reproducing paper-style metrics requires downloading Kaggle datasets per `ml/scripts/download_datasets.py` and training locally; that path is separate from “run the app.”
|
docs/DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CropIntel Production Deployment (single VPS)
|
| 2 |
+
|
| 3 |
+
One Docker container runs both the Next.js web app and the Python inference
|
| 4 |
+
service (supervisord manages the two processes). Models are fetched once at
|
| 5 |
+
container start from a release zip. Right-sized for a single server — no
|
| 6 |
+
Kubernetes, no Redis, no external model registry.
|
| 7 |
+
|
| 8 |
+
## Architecture
|
| 9 |
+
|
| 10 |
+
```
|
| 11 |
+
internet ── Caddy (TLS, :443) ── Next.js (:3050, public)
|
| 12 |
+
│ POST /api/predict ──► FastAPI inference
|
| 13 |
+
│ GET /api/health ──► service (127.0.0.1:8000,
|
| 14 |
+
│ never exposed)
|
| 15 |
+
└─ models: ml/models/<crop>/<version>/model.tflite
|
| 16 |
+
audit log: data/predictions.jsonl
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
- `app/api/predict/route.ts` validates + rate-limits, then forwards the upload
|
| 20 |
+
to the inference service (`ml/serve/inference_app.py`), which keeps all crop
|
| 21 |
+
models loaded in memory (TFLite, ~9 MB per crop).
|
| 22 |
+
- `GET /api/health` aggregates web liveness + per-crop model readiness — point
|
| 23 |
+
the compose healthcheck and your uptime monitor at it.
|
| 24 |
+
|
| 25 |
+
## Prerequisites
|
| 26 |
+
|
| 27 |
+
- VPS with 2 vCPU / 4 GB RAM (TFLite backend; Keras would need ~4× more)
|
| 28 |
+
- Docker + compose plugin
|
| 29 |
+
- A domain pointed at the VPS (for TLS)
|
| 30 |
+
|
| 31 |
+
## First deploy
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
git clone <repo> /opt/cropintel && cd /opt/cropintel
|
| 35 |
+
|
| 36 |
+
# .env — models bundle + optional secrets
|
| 37 |
+
cat > .env <<'EOF'
|
| 38 |
+
CROPINTEL_MODELS_URL=https://github.com/rakshithj09/CropIntel/releases/download/v1/cropintel-models-mobile.zip
|
| 39 |
+
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=...
|
| 40 |
+
CROPINTEL_ADMIN_TOKEN=<random string> # protects POST /admin/reload
|
| 41 |
+
EOF
|
| 42 |
+
|
| 43 |
+
docker compose -f docker-compose.prod.yml up -d --build
|
| 44 |
+
curl -fsS http://localhost:3050/api/health # expect {"web":"ok","inference":{"ready":true,...}}
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
The models zip is produced by:
|
| 48 |
+
```bash
|
| 49 |
+
python -m ml.scripts.package_models --tflite-only -o cropintel-models-mobile.zip
|
| 50 |
+
```
|
| 51 |
+
and uploaded to a GitHub Release (or any direct-download URL).
|
| 52 |
+
|
| 53 |
+
### Reverse proxy (TLS)
|
| 54 |
+
|
| 55 |
+
Caddy on the host is the simplest option:
|
| 56 |
+
|
| 57 |
+
```
|
| 58 |
+
# /etc/caddy/Caddyfile
|
| 59 |
+
yourdomain.example {
|
| 60 |
+
reverse_proxy 127.0.0.1:3050
|
| 61 |
+
}
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
Caddy sets `X-Forwarded-For` automatically. The in-memory rate limiter keys on
|
| 65 |
+
the client IP — behind any proxy that does NOT set `X-Forwarded-For`, all
|
| 66 |
+
clients share one bucket. Verify your proxy sets it.
|
| 67 |
+
|
| 68 |
+
## Updating
|
| 69 |
+
|
| 70 |
+
| What changed | Do |
|
| 71 |
+
|---|---|
|
| 72 |
+
| Code | `git pull && docker compose -f docker-compose.prod.yml up -d --build` |
|
| 73 |
+
| Models (new bundle) | update `CROPINTEL_MODELS_URL`, then `rm ml/models/.cropintel-fetch-ok && docker compose -f docker-compose.prod.yml restart` |
|
| 74 |
+
| Models (promote a version already on disk) | see below |
|
| 75 |
+
|
| 76 |
+
**Gotcha:** `ml/models/.cropintel-fetch-ok` is a sentinel that suppresses
|
| 77 |
+
re-downloading the models bundle on every container start. A new bundle URL is
|
| 78 |
+
silently ignored until you delete this file.
|
| 79 |
+
|
| 80 |
+
## Model promotion / rollback
|
| 81 |
+
|
| 82 |
+
Versions live in `ml/models/<crop>/v1_YYYYMMDD_HHMMSS/`. The serving version is
|
| 83 |
+
pinned by `ml/models/<crop>/production.json`; without it, the latest complete
|
| 84 |
+
version serves (legacy behavior).
|
| 85 |
+
|
| 86 |
+
```bash
|
| 87 |
+
# status of every crop (serving version, test + external accuracy)
|
| 88 |
+
python -m ml.scripts.promote_model --status
|
| 89 |
+
|
| 90 |
+
# promote (gated on metrics.json accuracy + a passing external_eval.json)
|
| 91 |
+
python -m ml.scripts.promote_model --crop rice --version v1_20260612_103000
|
| 92 |
+
|
| 93 |
+
# instant rollback to the previous pointer
|
| 94 |
+
python -m ml.scripts.promote_model --crop rice --rollback
|
| 95 |
+
|
| 96 |
+
# apply without restarting the container
|
| 97 |
+
curl -X POST -H "X-Admin-Token: $CROPINTEL_ADMIN_TOKEN" localhost:8000/admin/reload
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
The promotion gate requires an external evaluation (out-of-training-distribution
|
| 101 |
+
images), produced with:
|
| 102 |
+
|
| 103 |
+
```bash
|
| 104 |
+
python -m ml.scripts.test_external --crop rice --path ml/field_test/rice --save-json
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
Never promote on in-dataset test accuracy alone — the rice and soybean models
|
| 108 |
+
both scored 100% in-dataset while failing badly on external images (shortcut
|
| 109 |
+
learning). The honest number is external accuracy.
|
| 110 |
+
|
| 111 |
+
## Monitoring & logs
|
| 112 |
+
|
| 113 |
+
- **Uptime**: point an external pinger (UptimeRobot / healthchecks.io free tier)
|
| 114 |
+
at `https://yourdomain.example/api/health` every minute. An on-box monitor
|
| 115 |
+
cannot alert you when the box itself dies.
|
| 116 |
+
- **Process restarts**: `restart: unless-stopped` + supervisord auto-restart
|
| 117 |
+
handle crashes; the compose healthcheck flags a wedged container.
|
| 118 |
+
- **Process logs**: `docker compose -f docker-compose.prod.yml logs -f`
|
| 119 |
+
(json-file driver rotates at 20 MB × 5 files).
|
| 120 |
+
- **Prediction audit log**: `data/predictions.jsonl` — one line per request
|
| 121 |
+
(crop, model version, disease, confidence, entropy, verification status,
|
| 122 |
+
image quality, latency, image sha256; no image bytes). Use it for drift
|
| 123 |
+
analysis: a rising `not_in_catalog`/`unknown` rate for a crop means the field
|
| 124 |
+
distribution is moving away from training.
|
| 125 |
+
|
| 126 |
+
Rotate it with host logrotate — `/etc/logrotate.d/cropintel`:
|
| 127 |
+
```
|
| 128 |
+
/opt/cropintel/data/predictions.jsonl {
|
| 129 |
+
size 50M
|
| 130 |
+
rotate 10
|
| 131 |
+
copytruncate
|
| 132 |
+
compress
|
| 133 |
+
missingok
|
| 134 |
+
}
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
## Backups
|
| 138 |
+
|
| 139 |
+
```bash
|
| 140 |
+
# nightly at 03:00 — models + pointers + audit log, keep 7
|
| 141 |
+
0 3 * * * /opt/cropintel/scripts/ops/backup.sh /opt/cropintel /var/backups/cropintel
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
Models are also re-fetchable from the release zip, so this is cheap insurance,
|
| 145 |
+
not a disaster-recovery plan. Add an `rclone copy` of `/var/backups/cropintel`
|
| 146 |
+
to object storage if you want offsite copies.
|
| 147 |
+
|
| 148 |
+
## Troubleshooting
|
| 149 |
+
|
| 150 |
+
| Symptom | Check |
|
| 151 |
+
|---|---|
|
| 152 |
+
| `/api/health` 503 | `curl localhost:8000/readyz` inside the container — shows per-crop load errors |
|
| 153 |
+
| "Model not ready" for one crop | that crop has no complete version dir; fetch models or train |
|
| 154 |
+
| Predictions slow / queueing | the service is single-worker by design (TFLite interpreters are not thread-safe); sustained load beyond ~10 req/s needs a second look |
|
| 155 |
+
| New models bundle ignored | delete `ml/models/.cropintel-fetch-ok` and restart |
|
| 156 |
+
| Rate limiting all users together | proxy not setting `X-Forwarded-For` |
|