diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
deleted file mode 100644
index 285ca3a..0000000
--- a/.github/CODEOWNERS
+++ /dev/null
@@ -1,11 +0,0 @@
-# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
-
-* @Oshgig @edoh-Onuh @franchaise @Goldokpa @Godswill-code @femi23 @emekambachu
-
-/docs/ @Oshgig @edoh-Onuh @franchaise @Goldokpa @Godswill-code @femi23 @emekambachu
-/notebooks/ @Oshgig @edoh-Onuh @franchaise @Goldokpa @Godswill-code @femi23 @emekambachu
-/src/ @Oshgig @edoh-Onuh @franchaise @Goldokpa @Godswill-code @femi23 @emekambachu
-/models/ @Goldokpa @Oshgig @franchaise @Godswill-code @emekambachu
-/models_pretrained/ @Goldokpa @Oshgig @Godswill-code @femi23 @emekambachu
-/frontend/ @cutewizzy11 @edoh-Onuh @Goldokpa @emekambachu
-/scripts/ @cutewizzy11 @Oshgig @Goldokpa @emekambachu
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..047198f
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,59 @@
+name: CI
+
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+ branches: [main, develop]
+
+jobs:
+ python:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+
+ - name: Install system dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libgl1
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements.txt
+ pip install -e .
+
+ - name: Lint with flake8
+ run: |
+ flake8 src/ --count --select=E9,F63,F7,F82 --show-source --statistics
+ flake8 src/ --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+
+ - name: Test with pytest
+ run: |
+ pytest tests/ -v --tb=short
+
+ frontend:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: frontend
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Type check and build
+ run: npm run build
diff --git a/.gitignore b/.gitignore
index cc51d47..4ba3bec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,10 +37,11 @@ ENV/
# Jupyter Notebook
.ipynb_checkpoints
*.ipynb
+!notebooks/*.ipynb
# Data
-data/
-datasets/
+/data/
+/datasets/
*.tif
*.tiff
*.h5
@@ -87,3 +88,11 @@ frontend/node_modules/
# Runtime outputs
outputs/
+
+# Service account keys — never commit these
+secrets/
+*.json
+
+# Large model files
+models/demo_run/
+*.pth
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bcba074..d29cd37 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -31,7 +31,33 @@ We are committed to providing a welcoming and inclusive environment. Please be r
#### First Time Contributors
-Look for issues labeled `good first issue` - these are specifically chosen for newcomers.
+Look for issues labeled `good first issue` — these are specifically chosen for newcomers.
+
+**Recommended first issues (ready to pick up):**
+
+| Issue | What You'll Learn | Time Estimate |
+|-------|-----------------|---------------|
+| [#9: Add frontend unit tests](https://github.com/Climate-Vision/ClimateVision/issues/9) | Vitest, React Testing Library, Vite | 2–4 hours |
+| [#13: Add Docker Compose](https://github.com/Climate-Vision/ClimateVision/issues/13) | Docker, multi-service orchestration | 3–6 hours |
+
+**How to claim an issue:**
+1. Read the issue description and acceptance criteria
+2. Comment "I'd like to work on this" — a maintainer will assign you
+3. Fork the repo and create a branch: `git checkout -b feature/issue-9-frontend-tests`
+4. Open a **draft PR** within 48 hours (even if incomplete) so we can give early feedback
+
+**Need help?** Tag `@Climate-Vision/maintainers` in the issue or open a [Discussion](https://github.com/Climate-Vision/ClimateVision/discussions).
+
+#### Intermediate Contributors
+
+Ready for something meatier? These issues close critical gaps in our production pipeline:
+
+| Issue | Area | Skills You'll Build |
+|-------|------|-------------------|
+| [#10: Alert delivery worker](https://github.com/Climate-Vision/ClimateVision/issues/10) | Backend | FastAPI BackgroundTasks, SMTP, webhooks |
+| [#11: WebSocket real-time updates](https://github.com/Climate-Vision/ClimateVision/issues/11) | Full-stack | FastAPI WebSockets, React hooks, graceful degradation |
+| [#12: ONNX Runtime inference](https://github.com/Climate-Vision/ClimateVision/issues/12) | MLOps | ONNX Runtime, PyTorch export, latency benchmarking |
+| [#14: Carbon analytics API](https://github.com/Climate-Vision/ClimateVision/issues/14) | Analytics | Feature flags, API schema design, geospatial math |
#### Development Process
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
deleted file mode 100644
index ba5c791..0000000
--- a/CONTRIBUTORS.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# Contributors
-
-- @Oshgig
-- @edoh-Onuh
-- @franchaise
-- @Goldokpa
-- @cutewizzy11
-- @Godswill-code
-- @femi23
-- @emekambachu
diff --git a/MAINTAINERS.md b/MAINTAINERS.md
deleted file mode 100644
index 9e7aeaa..0000000
--- a/MAINTAINERS.md
+++ /dev/null
@@ -1,11 +0,0 @@
-# Maintainers
-
-- @Oshgig — Data Science Maintainer
-- @edoh-Onuh — Data Science Maintainer
-- @franchaise — DS Maintainer
-- @Goldokpa — ML Engineer
-- @Godswill-code — Data Science Maintainer
-- @femi23 — Data Science Maintainer
-- @cutewizzy11 — Frontend Maintainer
-- @emekambachu - ML Engineer
-
diff --git a/README.md b/README.md
index f951d35..bafd9b8 100644
--- a/README.md
+++ b/README.md
@@ -1,832 +1,149 @@
-# ClimateVision 🌍🛰️
+# ClimateVision
[](https://opensource.org/licenses/MIT)
[](https://www.python.org/downloads/)
-[](CONTRIBUTING.md)
-
-**An open-source machine learning platform for automated deforestation detection using deep learning and satellite imagery data.**
-
-ClimateVision applies state-of-the-art computer vision and data science techniques to solve critical environmental challenges. We train deep learning models on massive satellite imagery datasets to detect forest loss, predict carbon emissions, and generate real-time alerts - making advanced ML accessible to organizations protecting the world's forests.
-
----
-
-## 🌟 The Data Science Challenge
-
-Detecting deforestation from satellite imagery is a complex **machine learning problem**:
-
-**Current Barriers:**
-- 🔬 **Complex ML Models Required** - Semantic segmentation, change detection, and time series analysis
-- 📊 **Massive Datasets** - Petabytes of multispectral satellite imagery requiring distributed processing
-- 🧮 **Feature Engineering** - Extracting meaningful patterns from 13-band Sentinel-2 imagery
-- ⚡ **Real-time Inference** - Processing new imagery within hours, not weeks
-- 🎯 **High Accuracy Needed** - False positives waste resources, false negatives miss illegal logging
-- 📈 **Uncertainty Quantification** - Models must provide confidence scores for predictions
-
-**Our Data Science Solution:**
-- ✅ **Pre-trained Deep Learning Models** - U-Net, ResNet, and Siamese networks optimized for satellite imagery
-- ✅ **Automated ML Pipeline** - From raw satellite data to predictions with minimal manual intervention
-- ✅ **Distributed Data Processing** - Dask/Ray for handling terabyte-scale image datasets
-- ✅ **Production MLOps** - Model versioning, A/B testing, and monitoring
-- ✅ **Advanced Computer Vision** - Multi-temporal analysis and spectral feature extraction
-- ✅ **Statistical Modeling** - Bayesian carbon estimation with uncertainty bounds
-
----
-
-## 🎯 Key Data Science Features
-
-### 🤖 Deep Learning Models
-- **Semantic Segmentation** - U-Net architecture for pixel-level forest/non-forest classification
-- **Change Detection** - Siamese CNNs for temporal comparison of satellite images
-- **Multi-task Learning** - Joint training for segmentation, change detection, and carbon estimation
-- **Transfer Learning** - Pre-trained on ImageNet, fine-tuned on forest datasets
-- **Model Ensemble** - Combine multiple architectures for robust predictions
-
-### 📊 Advanced Data Processing
-- **Multispectral Feature Extraction** - Process 13-band Sentinel-2 imagery (RGB + NIR + SWIR)
-- **Distributed Computing** - Dask/Ray for parallel processing of large image tiles
-- **Data Augmentation** - Rotation, flipping, spectral perturbations for robust training
-- **Cloud Masking** - Automated removal of cloudy pixels using ML classifiers
-- **Temporal Aggregation** - Time-series analysis to reduce noise and detect trends
-
-### 🧮 Statistical & Predictive Analytics
-- **Regression Models** - Random Forest and XGBoost for biomass/carbon estimation
-- **Uncertainty Quantification** - Monte Carlo Dropout and ensemble methods for confidence intervals
-- **Time Series Forecasting** - LSTM/Transformer models to predict future deforestation risk
-- **Anomaly Detection** - Isolation Forest for identifying unusual forest loss patterns
-- **Causal Inference** - Propensity score matching to attribute deforestation drivers
-
-### ⚡ Production ML Engineering
-- **Model Serving** - FastAPI with ONNX runtime for low-latency inference (<50ms)
-- **Batch Prediction Pipeline** - Process thousands of images in parallel
-- **Model Versioning** - MLflow for experiment tracking and model registry
-- **A/B Testing** - Deploy multiple model versions and compare performance
-- **Monitoring & Drift Detection** - Track prediction quality and data distribution shifts
-
-### 🔌 Data Pipeline & ETL
-- **Automated Data Ingestion** - Scheduled downloads from Sentinel Hub and Google Earth Engine APIs
-- **Feature Store** - Cache preprocessed features for faster training/inference
-- **Data Validation** - Great Expectations for quality checks on satellite imagery
-- **Version Control** - DVC for large dataset management
-- **Metadata Catalog** - Track provenance of every satellite image and prediction
-
----
-
-## 🏗️ Architecture
-
-ClimateVision is built on a modular, scalable architecture designed for production deployment:
-
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ SATELLITE DATA SOURCES │
-│ Sentinel-2 │ Landsat 8/9 │ Planet Labs │
-└────────────────────────────┬────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ DATA INGESTION LAYER │
-│ - Automated data fetching (Sentinel Hub API, Google Earth │
-│ Engine) │
-│ - Cloud storage (S3/GCS) with versioning │
-│ - Metadata cataloging and indexing │
-└────────────────────────────┬────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ PREPROCESSING PIPELINE │
-│ - Cloud masking and atmospheric correction │
-│ - Image normalization and augmentation │
-│ - Tile generation (256x256 patches) │
-│ - Distributed processing with Dask/Ray │
-└────────────────────────────┬────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ ML INFERENCE ENGINE │
-│ │
-│ ┌─────────────────┐ ┌──────────────────┐ ┌────────────────┐ │
-│ │ Segmentation │ │ Change Detection │ │ Carbon Stock │ │
-│ │ (U-Net) │ │ (Siamese Net) │ │ (Regression) │ │
-│ │ │ │ │ │ │ │
-│ │ Forest/Non- │ │ Before/After │ │ Biomass Est. │ │
-│ │ Forest Masks │ │ Comparison │ │ & CO2 Calc. │ │
-│ └─────────────────┘ └──────────────────┘ └────────────────┘ │
-│ │
-│ - PyTorch backend with ONNX export │
-│ - GPU acceleration (CUDA/ROCm) │
-│ - Model versioning and A/B testing │
-│ - Uncertainty quantification (Monte Carlo Dropout) │
-└────────────────────────────┬────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ POST-PROCESSING & ANALYTICS │
-│ - Spatial filtering and smoothing │
-│ - Area calculation and statistics │
-│ - Trend analysis and forecasting │
-│ - Alert generation and routing │
-└────────────────────────────┬────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ API & WEB LAYER │
-│ - FastAPI REST endpoints │
-│ - WebSocket for real-time updates │
-│ - React dashboard with Leaflet maps │
-│ - Authentication and rate limiting │
-└─────────────────────────────────────────────────────────────────┘
-```
-
-### Technology Stack
-
-**Core ML & Data Processing:**
-- PyTorch 2.0+ (model training and inference)
-- Rasterio, GDAL (geospatial data handling)
-- NumPy, Pandas (numerical computing)
-- Dask (distributed computing)
-- Scikit-learn (preprocessing and metrics)
-
-**Satellite Data:**
-- Sentinel Hub API
-- Google Earth Engine Python API
-- sentinelsat (Copernicus data access)
-
-**API & Backend:**
-- FastAPI (REST API framework)
-- PostgreSQL + PostGIS (spatial database)
-- Redis (caching and job queue)
-- Celery (asynchronous task processing)
-
-**Frontend:**
-- React 18+
-- Leaflet (interactive maps)
-- Recharts (data visualization)
-- TailwindCSS (styling)
-
-**Infrastructure:**
-- Docker & Docker Compose
-- Kubernetes (production deployment)
-- GitHub Actions (CI/CD)
-- AWS/GCP/Azure compatible
+[](https://fastapi.tiangolo.com)
+[](https://pytorch.org)
+[](CONTRIBUTING.md)
---
-## 🔬 Data Science Techniques Applied
-
-This project is fundamentally a **data science and ML engineering challenge**. Here's how we apply data science at every stage:
-
-### 1. Data Collection & Engineering
-**Problem:** Acquiring and processing petabytes of satellite imagery data
-- **ETL Pipelines** - Automated data extraction from APIs (Sentinel Hub, GEE)
-- **Data Validation** - Quality checks on imagery (cloud coverage, missing bands)
-- **Feature Engineering** - Calculate NDVI, EVI, moisture indices from raw spectral bands
-- **Data Versioning** - Track dataset versions for reproducibility (DVC)
-
-### 2. Exploratory Data Analysis
-**Problem:** Understanding patterns in multispectral time-series data
-- **Statistical Analysis** - Distribution of forest vs. non-forest pixels across regions
-- **Correlation Analysis** - Which spectral bands best discriminate forest types
-- **Temporal Patterns** - Seasonal vegetation cycles, deforestation trends
-- **Visualization** - False-color composites, spectral signatures, change matrices
-
-### 3. Model Development
-**Problem:** Training deep learning models on imbalanced, noisy satellite data
-- **Architecture Design** - Custom U-Net variants optimized for satellite imagery
-- **Loss Functions** - Focal loss and Dice loss for handling class imbalance
-- **Regularization** - Dropout, batch normalization, data augmentation
-- **Hyperparameter Tuning** - Optuna/Ray Tune for learning rate, batch size optimization
-- **Cross-validation** - Spatial CV to prevent data leakage across nearby tiles
-
-### 4. Model Evaluation & Selection
-**Problem:** Ensuring models generalize across different forest types and regions
-- **Metrics** - F1-score, IoU, precision-recall curves for segmentation
-- **Ablation Studies** - Impact of different input bands, architectures, training strategies
-- **Error Analysis** - Where and why models fail (edge cases, rare forest types)
-- **Benchmark Testing** - Performance on held-out test sets (Amazon, Congo, Southeast Asia)
-- **Uncertainty Quantification** - Calibration plots, confidence intervals
-
-### 5. Prediction & Inference
-**Problem:** Generating predictions at scale with low latency
-- **Model Optimization** - ONNX conversion, quantization, pruning for speed
-- **Batch Processing** - Parallelize inference across thousands of image tiles
-- **Post-processing** - Morphological operations to smooth predictions
-- **Ensemble Methods** - Combine predictions from multiple models
-- **Confidence Thresholding** - Only alert when model is highly confident
-
-### 6. Time Series Analysis
-**Problem:** Detecting change over time in noisy temporal data
-- **Trend Detection** - CUSUM, Mann-Kendall tests for significant forest loss
-- **Change Point Detection** - Identify exact timing of deforestation events
-- **Forecasting** - ARIMA, Prophet, LSTM for predicting future deforestation risk
-- **Anomaly Detection** - Flag unusual patterns (rapid clearing, irregular shapes)
-
-### 7. Statistical Modeling
-**Problem:** Estimating carbon stocks with uncertainty
-- **Regression** - Random Forest, XGBoost for biomass-to-carbon conversion
-- **Feature Selection** - Which variables best predict carbon density
-- **Uncertainty Propagation** - Bootstrap, Bayesian methods for error bars
-- **Spatial Statistics** - Account for spatial autocorrelation in carbon estimates
-
-### 8. MLOps & Production
-**Problem:** Maintaining model performance in production
-- **Continuous Training** - Retrain models as new labeled data arrives
-- **Model Monitoring** - Track prediction drift, data distribution shifts
-- **A/B Testing** - Compare new model versions against production baseline
-- **Logging & Debugging** - Trace predictions back to input data and model version
-- **Scalability** - Kubernetes autoscaling based on inference load
-
-**Why This is Data Science:**
-This isn't just "analyzing satellite images" - it's building an end-to-end ML system that handles big data, trains neural networks, performs statistical inference, and deploys models to production. The remote sensing aspect is the *domain*, but data science and ML engineering are the *methods*.
+## What is ClimateVision?
----
-
-## 👥 Team & Roles
-
-ClimateVision is developed by a team of data science engineers committed to using AI for climate action:
-
-### **Technical Lead & Computer Vision Architect**
-- Overall system architecture and technical direction
-- Computer vision model development and optimization
-- Research and implementation of state-of-the-art segmentation models
-- Code review and quality assurance
-- Integration of ML components into production pipeline
-
-### **Data Science Engineer 1 - ML Model Development Lead**
-- Design and train deep learning models for forest segmentation
-- Implement change detection algorithms (Siamese networks, temporal CNNs)
-- Model evaluation, hyperparameter tuning, and performance optimization
-- Create model benchmarking framework
-- Research paper implementation and adaptation
-
-### **Data Science Engineer 2 - Data Pipeline & Engineering Lead**
-- Build automated satellite data ingestion pipelines
-- Develop preprocessing workflows (cloud masking, normalization, tiling)
-- Implement distributed data processing with Dask/Ray
-- Create data versioning and cataloging system
-- Optimize storage and retrieval for large-scale satellite imagery
-
-### **Data Science Engineer 3 - Carbon Analytics & Validation Lead**
-- Develop carbon stock estimation models
-- Implement biomass regression algorithms
-- Create uncertainty quantification framework
-- Validate model outputs against ground truth data
-- Generate impact reports and scientific metrics
-
-### **Data Science Engineer 4 - API Development & Deployment Lead**
-- Build FastAPI backend for model serving
-- Implement batch and real-time inference endpoints
-- Create monitoring and logging infrastructure
-- Develop alert notification system
-- Deploy and maintain production infrastructure
-
-### Development Workflow
-
-Our team follows agile methodology with 2-week sprints:
-
-**Weekly Sync:**
-- Monday: Sprint planning and task assignment
-- Wednesday: Technical deep-dive and pair programming
-- Friday: Demo progress and code review
-
-**Collaboration:**
-- GitHub Projects for task tracking
-- Pull request reviews within 24 hours
-- Weekly technical blog post from rotating team member
-- Monthly community showcase of new features
+ClimateVision is an open-source machine learning platform that detects environmental change from satellite imagery. It uses deep learning (U-Net, Siamese networks) to monitor **deforestation**, **arctic ice melting**, and **flooding** — giving conservation NGOs and researchers automated alerts without manual analysis. Built on Sentinel-2 and Landsat data via Google Earth Engine, it runs as a REST API with a React dashboard for real-time monitoring.
---
-## 📅 3-Month Execution Plan
-
-### Month 1: Foundation (Weeks 1-4)
-
-**Week 1-2: Architecture & Setup**
-- Repository structure and CI/CD pipeline
-- Data ingestion pipeline for Sentinel-2/Landsat
-- Initial dataset curation (Amazon, Congo Basin)
-- Team onboarding and tooling setup
-- **Deliverable:** Project architecture document + data pipeline
-
-**Week 3-4: Core ML Models**
-- Implement U-Net for forest segmentation
-- Train baseline model on public datasets
-- Model evaluation framework
-- First tutorial notebook
-- **Deliverable:** Working segmentation model + documentation
-
-### Month 2: Advanced Features (Weeks 5-8)
-
-**Week 5-6: Change Detection**
-- Siamese network for temporal comparison
-- Carbon estimation regression models
-- Model optimization and benchmarking
-- **Deliverable:** Multi-model inference pipeline
-
-**Week 7-8: API & Integration**
-- FastAPI backend with prediction endpoints
-- Batch processing system
-- Database setup (PostgreSQL + PostGIS)
-- Authentication and rate limiting
-- **Deliverable:** Production-ready API + integration docs
-
-### Month 3: Deployment & Growth (Weeks 9-12)
-
-**Week 9-10: User Interface**
-- React dashboard with Leaflet maps
-- Real-time alert notification system
-- Interactive visualization components
-- **Deliverable:** Full-stack web application
-
-**Week 11-12: Launch & Scale**
-- Docker containerization
-- Deployment documentation
-- Comprehensive API reference
-- Case study demonstrations (3 regions)
-- Community launch campaign
-- **Deliverable:** v1.0 Release + launch materials
-
----
-
-## 🚀 Getting Started
-
-### Prerequisites
-
-```bash
-Python 3.8 or higher
-CUDA 11.8+ (for GPU acceleration, optional)
-Docker (for containerized deployment, optional)
-```
-
-### Installation
-
-#### Option 1: pip install (recommended)
+## Installation
```bash
-# Clone the repository
-git clone https://github.com/yourusername/ClimateVision.git
+git clone https://github.com/Climate-Vision/ClimateVision.git
cd ClimateVision
-
-# Create virtual environment
-python -m venv venv
-source venv/bin/activate # On Windows: venv\Scripts\activate
-
-# Install dependencies
pip install -r requirements.txt
-
-# Install ClimateVision
-pip install -e .
```
-#### Option 2: Docker
-
-```bash
-# Build the Docker image
-docker build -t climatevision:latest .
-
-# Run the container
-docker run -p 8000:8000 climatevision:latest
-```
+---
-### Quick Start
+## Quickstart
-#### 1. Download Pre-trained Models
+**Start the API server:**
```bash
-# Download our pre-trained models
-python scripts/download_models.py
+uvicorn climatevision.api.main:app --reload --host 0.0.0.0 --port 8000
```
-#### 2. Process Your First Satellite Image
-
-```python
-from climatevision import ForestDetector
-from climatevision.data import load_sentinel2_image
-
-# Initialize the detector
-detector = ForestDetector(model_path="models/unet_forest_v1.pth")
-
-# Load satellite image
-image = load_sentinel2_image(
- coordinates=(lat, lon),
- date_range=("2024-01-01", "2024-01-31"),
- cloud_coverage_max=20
-)
+**Run a deforestation analysis:**
-# Run detection
-result = detector.predict(image)
-
-# Visualize results
-result.plot(show_confidence=True, save_path="forest_mask.png")
-
-# Get statistics
-stats = result.get_statistics()
-print(f"Forest area: {stats['forest_area_km2']:.2f} km²")
-print(f"Deforested area: {stats['deforested_area_km2']:.2f} km²")
-print(f"Carbon loss: {stats['carbon_loss_tons']:.2f} tons CO2")
-```
-
-#### 3. Detect Deforestation Over Time
-
-```python
-from climatevision import ChangeDetector
-
-# Initialize change detector
-change_detector = ChangeDetector()
-
-# Compare two time periods
-change_map = change_detector.detect_change(
- before_date="2023-01-01",
- after_date="2024-01-01",
- region_bounds=(min_lat, min_lon, max_lat, max_lon)
-)
-
-# Generate alert if deforestation detected
-if change_map.has_significant_change(threshold=0.05): # 5% change
- alert = change_map.generate_alert()
- alert.send(method="email", recipients=["forest-watch@ngo.org"])
+```bash
+curl -X POST http://localhost:8000/api/predict/json \
+ -H "Content-Type: application/json" \
+ -d '{
+ "bbox": [-60.0, -15.0, -45.0, -5.0],
+ "start_date": "2023-01-01",
+ "end_date": "2023-12-31",
+ "analysis_type": "deforestation"
+ }'
```
-#### 4. Launch Web Dashboard
+**Launch the dashboard:**
```bash
-# Start the API server
-uvicorn climatevision.api.main:app --reload --port 8000
-
-# In another terminal, start the frontend
-cd frontend
-npm install
-npm run dev
-
+cd frontend && npm install && npm run dev
# Visit http://localhost:5173
```
----
-
-## 📖 Documentation
-
-Comprehensive documentation is available at [docs.climatevision.org](https://docs.climatevision.org):
-
-- **[Getting Started Guide](docs/getting-started.md)** - Installation and basic usage
-- **[API Reference](docs/api-reference.md)** - Complete API documentation
-- **[Model Documentation](docs/models.md)** - Details on pre-trained models
-- **[Tutorials](docs/tutorials/)** - Step-by-step examples
-- **[Deployment Guide](docs/deployment.md)** - Production deployment instructions
-- **[Contributing Guide](CONTRIBUTING.md)** - How to contribute to ClimateVision
-
----
-
-## 🎓 Example Use Cases
-
-### 1. Monitor Protected Areas
-Track deforestation in national parks and conservation areas:
-```python
-from climatevision import ProtectedAreaMonitor
-
-monitor = ProtectedAreaMonitor(
- area_name="Amazon Rainforest Reserve",
- bounds=(-3.4653, -62.2159, -3.0653, -61.8159)
-)
-
-# Set up weekly monitoring
-monitor.schedule_monitoring(
- frequency="weekly",
- alert_threshold=0.01, # Alert on 1% forest loss
- notification_channels=["email", "slack"]
-)
-```
-
-### 2. Carbon Credit Verification
-Validate carbon sequestration for conservation projects:
-```python
-from climatevision import CarbonVerifier
-
-verifier = CarbonVerifier()
-
-# Analyze project area
-carbon_report = verifier.generate_report(
- project_area=project_polygon,
- baseline_year=2020,
- current_year=2024
-)
-
-print(carbon_report.summary())
-# Output: "Total carbon sequestered: 12,450 tons CO2"
-# "Avoided emissions from deforestation: 3,200 tons CO2"
-```
-
-### 3. Research & Analysis
-Analyze deforestation trends across regions:
-```python
-from climatevision import TrendAnalyzer
-
-analyzer = TrendAnalyzer()
-
-# Compare multiple regions
-results = analyzer.compare_regions(
- regions=["Amazon", "Congo Basin", "Southeast Asia"],
- time_range=("2020-01-01", "2024-01-01"),
- metrics=["deforestation_rate", "carbon_loss", "forest_fragmentation"]
-)
-
-# Generate scientific report
-analyzer.export_report(results, format="pdf", include_plots=True)
-```
-
----
-
-## 🗺️ Roadmap
-
-### Month 1: Foundation & Core Models (Weeks 1-4)
-- [ ] Project setup and architecture documentation
-- [ ] Satellite data ingestion pipeline (Sentinel-2, Landsat)
-- [ ] Basic forest segmentation model (U-Net)
-- [ ] Data preprocessing workflows
-- [ ] Initial model training on public datasets
-- [ ] **Community Goal:** 50+ GitHub stars, initial documentation
-
-### Month 2: Advanced Features & API (Weeks 5-8)
-- [ ] Change detection algorithms implementation
-- [ ] Carbon estimation models
-- [ ] REST API development with FastAPI
-- [ ] Model optimization and performance tuning
-- [ ] Batch processing pipeline
-- [ ] Tutorial notebooks and examples
-- [ ] **Community Goal:** 150+ stars, 10+ forks, first external contributors
-
-### Month 3: Deployment & Scale (Weeks 9-12)
-- [ ] Web dashboard with interactive maps
-- [ ] Real-time alert notification system
-- [ ] Docker containerization and deployment
-- [ ] Comprehensive documentation and API reference
-- [ ] Case studies and demo applications
-- [ ] Scientific validation and benchmarking
-- [ ] **Community Goal:** 300+ stars, 25+ forks, 5+ active contributors, partnerships with 2-3 NGOs
-
-### Post-Launch (Month 4+)
-- [ ] Multi-sensor fusion (Radar integration)
-- [ ] Mobile app for field verification
-- [ ] Integration with UN REDD+ reporting
-- [ ] Global forest monitoring dashboard
-- [ ] Academic paper publication
+**Explore the API docs:** `http://localhost:8000/docs`
---
-## 📊 Performance Benchmarks
-
-Our models achieve state-of-the-art performance on standard forest monitoring benchmarks:
-
-| Metric | ClimateVision | Industry Average |
-|--------|---------------|------------------|
-| Forest Segmentation Accuracy | 96.3% | 91.2% |
-| Change Detection F1-Score | 94.8% | 88.5% |
-| Carbon Estimation RMSE | 12.3 tons/ha | 18.7 tons/ha |
-| Inference Time (256x256 tile) | 45ms | 180ms |
-| Alert Latency | <24 hours | 7-14 days |
-
-*Benchmarks conducted on standard test datasets (ForestNet, TreeSatAI)*
-
----
+## Features
-## 🚀 Community Growth Strategy
-
-We're building ClimateVision in public to maximize impact and collaboration. Our 3-month launch strategy:
-
-### Engagement Initiatives
-
-**Week 1-4: Foundation**
-- Launch announcement on r/MachineLearning, r/ClimateChange, r/DataScience
-- Share architecture blog post on Medium/Dev.to
-- Engage with climate tech and ML communities on Twitter/LinkedIn
-- Create YouTube walkthrough of the project vision
-- Target: 50+ stars, establish presence
-
-**Week 5-8: Building Momentum**
-- Release tutorial notebooks and documentation
-- Present at online ML meetups and climate tech forums
-- Collaborate with environmental researchers for early testing
-- Share progress updates and technical deep-dives
-- Launch weekly "Office Hours" on Discord/Slack
-- Target: 150+ stars, 10+ forks, first external PRs
-
-**Week 9-12: Scale & Impact**
-- Release v1.0 with full documentation
-- Partner with 2-3 NGOs for pilot deployments
-- Submit to conferences (NeurIPS Climate Change Workshop, AGU)
-- Create demo videos showing real deforestation detection
-- Feature on ProductHunt, HackerNews, ShowHN
-- Engage with Hugging Face and Papers with Code communities
-- Target: 300+ stars, 25+ forks, active contributor base
-
-### Community Channels
-
-- **GitHub Discussions** - Technical questions, feature requests, announcements
-- **Discord Server** - Real-time collaboration, office hours, contributor chat
-- **Twitter** - Project updates, research highlights, community spotlights
-- **LinkedIn** - Professional networking, partnership opportunities
-- **Monthly Newsletter** - Progress reports, contributor highlights, use cases
-
-### Contributor Recognition
-
-- **Hall of Fame** - Recognize top contributors in README
-- **Contributor Badges** - Based on contribution type and impact
-- **Co-authorship** - On academic papers using ClimateVision
-- **Speaking Opportunities** - Present at conferences and meetups
-
-### GitHub Growth Tracking
-
-We monitor our repository's growth weekly to ensure we're building a thriving community:
-
-**Metrics Dashboard:**
-- **Stars**: Weekly growth rate and total count
-- **Forks**: Active forks vs. total forks ratio
-- **Contributors**: New vs. returning contributors
-- **Issues/PRs**: Response time and merge rate
-- **Community Health**: Discussion activity and sentiment
-
-**Growth Milestones:**
-- ⭐ 50 stars → Feature on trending repositories
-- ⭐ 100 stars → Launch on ProductHunt
-- ⭐ 200 stars → Partner announcements and case studies
-- ⭐ 300 stars → Conference presentation submissions
-- ⭐ 500 stars → v2.0 planning with community input
-
-**Community Building Tactics:**
-- **Good First Issues**: Label beginner-friendly tasks
-- **Hacktoberfest**: Participate in annual open source event
-- **Bounty Program**: Reward complex contributions
-- **Partner Showcases**: Feature NGO deployments and use cases
-- **Monthly Updates**: Transparent progress reports
+- **Multi-type climate analysis** — deforestation, arctic ice melting, flood detection (drought and wildfire detection planned)
+- **Deep learning inference** — U-Net semantic segmentation and Siamese network change detection on Sentinel-2 imagery
+- **Automated data pipeline** — Google Earth Engine integration with cloud masking, normalization, and 256×256 tiling
+- **NGO management** — register organisations, subscribe to regions, receive threshold-based alerts via email or webhook
+- **REST API** — FastAPI backend with paginated run history, stats endpoint, and full OpenAPI docs
+- **React dashboard** — interactive map with bbox region selector, Recharts analytics, confidence gauges, and run history
+- **MLflow experiment tracking** — log training runs, hyperparameters, and model checkpoints
+- **ONNX export** — optimised model export for fast production inference
---
-## 🌍 Target Impact & Potential Users
+## Documentation
-ClimateVision aims to serve:
+Full documentation: [github.com/Climate-Vision/ClimateVision/wiki](https://github.com/Climate-Vision/ClimateVision/wiki)
-- **Conservation NGOs** monitoring protected areas in developing regions (Amazon, Congo Basin, Southeast Asia)
-- **Environmental research institutions** studying deforestation patterns and climate impacts
-- **Government agencies** in resource-limited countries tracking illegal logging
-- **Carbon offset verification bodies** ensuring integrity of forest conservation projects
-- **Climate activists and citizen scientists** raising awareness about deforestation
-
-**Projected Impact (3-Month Goals):**
-- 🌲 Enable monitoring of **100,000+ hectares** across 3 pilot regions
-- 🚨 Generate **50+ deforestation alerts** for partner organizations
-- 📊 Track carbon emissions from forest loss in real-time
-- 🔬 Support **2-3 research projects** with open datasets
-- 🤝 Partner with **3-5 conservation organizations**
-
-**Long-term Vision (12 months):**
-- 🌍 Global coverage of priority deforestation hotspots
-- 🏆 Become the go-to open-source tool for forest monitoring
-- 📈 10,000+ hectares monitored per NGO partner
-- 🎓 Integration into university curricula for remote sensing courses
+- [Getting Started](GETTING_STARTED.md)
+- [API Reference](docs/API_REFERENCE.md) — `http://localhost:8000/docs` when running locally
+- [Project Structure](PROJECT_STRUCTURE.md)
+- [Training Guide](notebooks/01_getting_started.md)
+- [Colab Notebook](notebooks/train_on_colab.ipynb)
---
-## 📈 Project Metrics & Growth
+## Models & Analysis Types
-We track our progress transparently to demonstrate impact and community engagement:
+| Analysis Type | Status | Classes | Satellite Bands |
+|--------------|--------|---------|----------------|
+| Deforestation | Active | forest, non-forest | B02, B03, B04, B08 |
+| Arctic Ice Melting | Active | sea-ice, open-water, land | B02, B03, B04, B11 |
+| Flood Detection | Active | water, flooded, dry-land | B03, B08, B11 |
+| Drought Monitoring | Planned | normal, stressed, severe | B04, B08, B11, B12 |
+| Wildfire Detection | Planned | unburned, burned, active-fire | B04, B08, B11, B12 |
-### Technical Metrics
-- **Code Quality**: Test coverage >80%, CI/CD passing
-- **Model Performance**: Benchmarked against public datasets monthly
-- **Documentation Coverage**: All API endpoints and modules documented
-- **Response Time**: API latency <100ms for single predictions
+**Performance benchmarks** (baseline U-Net on held-out test sets):
-### Community Metrics
-- **GitHub Stars**: Tracking growth week-over-week
-- **Contributors**: Active and total contributor count
-- **Forks**: Projects building on ClimateVision
-- **Issues & PRs**: Community engagement and collaboration
-- **Downloads**: PyPI package downloads per month
+| Metric | Value |
+|--------|-------|
+| Forest segmentation IoU | in progress |
+| Change detection F1 | in progress |
+| Inference time (256×256 tile) | ~45ms on CPU |
+| API response time | <100ms |
-### Impact Metrics
-- **Hectares Monitored**: Total area under surveillance
-- **Alerts Generated**: Deforestation events detected
-- **Partner Organizations**: NGOs and institutions using the platform
-- **Research Citations**: Academic papers referencing ClimateVision
-
-All metrics are updated monthly in our [Project Dashboard](https://github.com/Climate-Vision/ClimateVision/wiki/Metrics).
+*Benchmarks will be updated as the team completes training runs. See [MLflow tracking](logs/) for experiment history.*
---
-## 🤝 Contributing
-
-We welcome contributions from the community! ClimateVision thrives on collaboration from data scientists, environmental researchers, and developers worldwide.
+## Contributing
-**Ways to contribute:**
-- 🐛 Report bugs and issues
-- 💡 Suggest new features or improvements
-- 📝 Improve documentation
-- 🔬 Add new models or datasets
-- 🌍 Translate the interface
-- 💻 Submit pull requests
-
-Please read our [Contributing Guide](CONTRIBUTING.md) and [Code of Conduct](CODE_OF_CONDUCT.md) before getting started.
-
-### Development Setup
+We welcome contributions — bug reports, new analysis types, model improvements, documentation, and translations.
```bash
-# Fork and clone the repo
-git clone https://github.com/Climate-Vision/ClimateVision.git
-cd ClimateVision
-
-# Create a development branch
+# Fork the repo, then:
git checkout -b feature/your-feature-name
-
-# Install development dependencies
-pip install -r requirements-dev.txt
-
-# Run tests
+pip install -r requirements.txt
pytest tests/
-
-# Run linting
-black src/
-flake8 src/
-mypy src/
-
-# Submit your PR!
+# Submit your PR against the develop branch
```
----
+Read the [Contributing Guide](CONTRIBUTING.md) and [Code of Conduct](CODE_OF_CONDUCT.md) before getting started.
-## 📜 License
-
-This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
-
-We chose MIT to maximize accessibility and encourage both academic and commercial applications that benefit forest conservation.
+Good first issues are labelled [`good first issue`](https://github.com/Climate-Vision/ClimateVision/issues?q=label%3A%22good+first+issue%22) on GitHub.
---
-## 🙏 Acknowledgments
-
-ClimateVision builds upon the work of the scientific community:
-
-- **Sentinel-2 & Landsat Programs** - Free satellite data from ESA and NASA
-- **Google Earth Engine** - Cloud-based geospatial analysis platform
-- **PyTorch & Hugging Face** - Deep learning frameworks and model hubs
-- **OpenForest** - Open datasets for forest monitoring research
-- **REDD+** - UN framework for forest conservation
-
-We thank all contributors, early adopters, and conservation partners who make this work possible.
-
----
+## License & Citation
-## 📞 Contact & Support
-
-- **Website:** [climatevision.org](https://climatevision.org)
-- **GitHub Issues:** [Report bugs or request features](https://github.com/Climate-Vision/ClimateVision/issues)
-- **Discussions:** [Join our community forum](https://github.com/Climate-Vision/ClimateVision/discussions)
-- **Twitter:** [@ClimateVisionAI](https://twitter.com/ClimateVisionAI)
-- **Slack:** [Join our developer community](https://join.slack.com/climatevision)
-
----
-
-## 📈 Citation
+This project is licensed under the **MIT License** — see [LICENSE](LICENSE) for details.
If you use ClimateVision in your research, please cite:
```bibtex
-@software{climatevision2025,
- author = {ClimateVision Contributors},
- title = {ClimateVision: Open-Source AI Platform for Deforestation Monitoring},
- year = {2025},
- url = {https://github.com/Climate-Vision/ClimateVision},
- version = {0.1.0}
+@software{climatevision2026,
+ author = {ClimateVision Contributors},
+ title = {ClimateVision: Open-Source AI Platform for Environmental Monitoring},
+ year = {2026},
+ url = {https://github.com/Climate-Vision/ClimateVision},
+ version = {0.2.0}
}
```
---
-## ⭐ Support the Project
-
-If you find ClimateVision useful for your research, conservation work, or just believe in our mission, please consider:
-
-- **Starring** ⭐ the repository to help others discover it
-- **Forking** 🍴 to build your own applications
-- **Contributing** 🤝 code, documentation, or ideas
-- **Sharing** 📢 with your network and communities
-- **Partnering** 🌍 if you're an NGO or research institution
-
-Every star helps us reach more people who can benefit from free, open-source forest monitoring!
-
-**Track our growth:** [Star History](https://star-history.com/#yourusername/ClimateVision&Date)
-
----
-
- Together, we can protect the world's forests through open-source AI.
-
-
⭐ Star us on GitHub
- ·
- 🤝 Contribute
- ·
- 🐛 Report Bug
- ·
- 📖 Documentation
+ ·
+ Contribute
+ ·
+ Report a Bug
-
----
-
-**Made with 🌍 for a sustainable future**
diff --git a/config.yaml b/config.yaml
index 33ff733..2ce5c8a 100644
--- a/config.yaml
+++ b/config.yaml
@@ -1,6 +1,121 @@
# ClimateVision Configuration
+# Multi-Climate Analysis Platform
-# Model Configuration
+# ===== Analysis Types Configuration =====
+# Each analysis type can be enabled/disabled and configured independently
+analysis_types:
+ # Deforestation Detection
+ deforestation:
+ enabled: true
+ display_name: "Deforestation Detection"
+ description: "Monitor forest coverage and detect deforestation events"
+ model:
+ architecture: "unet"
+ weights: "models/unet_deforestation.pth"
+ in_channels: 4
+ num_classes: 2
+ bands: ["B04", "B03", "B02", "B08"] # Red, Green, Blue, NIR
+ classes: ["non_forest", "forest"]
+ thresholds:
+ alert_forest_loss: 5.0 # Alert if >5% forest loss
+ critical_forest_loss: 15.0 # Critical if >15% loss
+ min_forest_coverage: 20.0 # Alert if coverage drops below 20%
+ metrics:
+ - "forest_percentage"
+ - "forest_pixels"
+ - "ndvi_stats"
+ - "carbon_estimation"
+
+ # Arctic Ice Melting
+ ice_melting:
+ enabled: true
+ display_name: "Arctic Ice Melting"
+ description: "Monitor sea ice extent and melting patterns in polar regions"
+ model:
+ architecture: "unet"
+ weights: "models/unet_ice.pth"
+ in_channels: 4
+ num_classes: 3
+ bands: ["B02", "B03", "B04", "B11"] # Blue, Green, Red, SWIR
+ classes: ["open_water", "sea_ice", "land"]
+ thresholds:
+ alert_ice_loss: 10.0 # Alert if >10% ice loss
+ critical_ice_loss: 25.0 # Critical if >25% loss
+ min_ice_concentration: 15.0 # Alert if concentration below 15%
+ rapid_melt_rate: 5.0 # km²/day threshold
+ metrics:
+ - "ice_percentage"
+ - "ice_extent_km2"
+ - "melt_rate"
+ - "ndsi_stats"
+ # Specific regions for Arctic monitoring
+ default_regions:
+ arctic_ocean: [-180, 66.5, 180, 90]
+ greenland: [-73, 60, -12, 84]
+ antarctica: [-180, -90, 180, -60]
+
+ # Flood Detection
+ flooding:
+ enabled: true
+ display_name: "Flood Detection"
+ description: "Detect and monitor flooding events and affected areas"
+ model:
+ architecture: "unet"
+ weights: "models/unet_flood.pth"
+ in_channels: 3
+ num_classes: 3
+ bands: ["B03", "B08", "B11"] # Green, NIR, SWIR
+ classes: ["dry_land", "permanent_water", "flooded"]
+ thresholds:
+ alert_flood_area: 5.0 # Alert if >5% area flooded
+ critical_flood_area: 20.0 # Critical if >20% flooded
+ rapid_expansion_rate: 10.0 # % increase per day
+ metrics:
+ - "flooded_percentage"
+ - "flooded_area_km2"
+ - "mndwi_stats"
+
+ # Drought Monitoring
+ drought:
+ enabled: false # Not yet implemented
+ display_name: "Drought Monitoring"
+ description: "Monitor vegetation stress and drought conditions"
+ model:
+ architecture: "unet"
+ weights: "models/unet_drought.pth"
+ in_channels: 4
+ num_classes: 4
+ bands: ["B04", "B08", "B11", "B12"] # Red, NIR, SWIR-1, SWIR-2
+ classes: ["normal", "mild_stress", "moderate_stress", "severe_drought"]
+ thresholds:
+ alert_drought_index: 0.3
+ critical_drought_index: 0.6
+ metrics:
+ - "drought_severity_index"
+ - "vegetation_health_index"
+ - "soil_moisture_proxy"
+
+ # Wildfire Detection
+ wildfire:
+ enabled: false # Not yet implemented
+ display_name: "Wildfire Detection"
+ description: "Detect active fires and burned areas"
+ model:
+ architecture: "unet"
+ weights: "models/unet_wildfire.pth"
+ in_channels: 4
+ num_classes: 3
+ bands: ["B04", "B08", "B11", "B12"] # Red, NIR, SWIR-1, SWIR-2
+ classes: ["unburned", "burned", "active_fire"]
+ thresholds:
+ fire_radiative_power: 10.0 # MW
+ burned_area_alert: 1.0 # km²
+ metrics:
+ - "burned_area_km2"
+ - "fire_intensity"
+ - "nbr_stats" # Normalized Burn Ratio
+
+# ===== Default Model Configuration =====
model:
architecture: "unet"
in_channels: 4 # RGB + NIR
@@ -8,7 +123,7 @@ model:
use_uncertainty: false
dropout_rate: 0.5
-# Training Configuration
+# ===== Training Configuration =====
training:
batch_size: 8
num_epochs: 50
@@ -19,7 +134,7 @@ training:
checkpoint_interval: 5
early_stopping_patience: 10
-# Data Configuration
+# ===== Data Configuration =====
data:
image_size: [256, 256]
bands: ["Red", "Green", "Blue", "NIR"]
@@ -29,18 +144,27 @@ data:
val_split: 0.1
test_split: 0.1
-# Satellite Data Sources
+# ===== Satellite Data Sources =====
satellite:
sentinel2:
bands: ["B04", "B03", "B02", "B08"] # Red, Green, Blue, NIR
+ all_bands: ["B01", "B02", "B03", "B04", "B05", "B06", "B07", "B08", "B8A", "B09", "B10", "B11", "B12"]
resolution: 10 # meters
cloud_coverage_max: 20 # percentage
+ revisit_time: 5 # days
landsat8:
bands: ["B4", "B3", "B2", "B5"]
resolution: 30 # meters
+ revisit_time: 16 # days
+
+ modis:
+ bands: ["1", "2", "3", "4", "5", "6", "7"]
+ resolution: 250 # meters (bands 1-2), 500m (3-7)
+ revisit_time: 1 # days
+ use_for: ["ice_melting", "wildfire"] # Best for large-scale monitoring
-# Inference Configuration
+# ===== Inference Configuration =====
inference:
batch_size: 4
threshold: 0.5
@@ -49,23 +173,34 @@ inference:
device: "cuda" # cuda or cpu
num_workers: 4
-# MLOps Configuration
+# ===== MLOps Configuration =====
mlops:
experiment_tracking: "mlflow" # mlflow, wandb, or none
model_registry: "mlflow"
logging_interval: 10 # log every N batches
-# Paths
+# ===== Paths =====
paths:
data_dir: "data/"
models_dir: "models/"
logs_dir: "logs/"
outputs_dir: "outputs/"
-# API Configuration
+# ===== API Configuration =====
api:
host: "0.0.0.0"
port: 8000
workers: 4
timeout: 60
max_file_size: 100 # MB
+ cors_origins:
+ - "http://localhost:5173"
+ - "http://localhost:3000"
+
+# ===== Organization (NGO) Configuration =====
+organizations:
+ enable_registration: true
+ require_email_verification: false
+ default_alert_channels: ["email"]
+ max_subscriptions_per_org: 10
+ api_rate_limit: 100 # requests per minute
\ No newline at end of file
diff --git a/config/train.yaml b/config/train.yaml
new file mode 100644
index 0000000..34bb9e8
--- /dev/null
+++ b/config/train.yaml
@@ -0,0 +1,62 @@
+# ============================================================
+# ClimateVision — Forest Segmentation Training Config
+# ============================================================
+# Usage:
+# python scripts/train.py --config config/train.yaml
+#
+# All paths are relative to the project root unless absolute.
+# ============================================================
+
+# --- Data --------------------------------------------------
+data:
+ dir: data/processed # root with train/ val/ test/ splits
+ image_size: 256 # spatial crop size (pixels)
+ batch_size: 16
+ num_workers: 4
+ use_weighted_sampler: true # oversample forest-rich patches
+ pin_memory: true
+
+# --- Model -------------------------------------------------
+model:
+ architecture: attention_unet # "unet" | "attention_unet"
+ in_channels: 4 # R, G, B, NIR
+ num_classes: 2 # 0=non-forest, 1=forest
+ bilinear: true # bilinear up-sampling (UNet only)
+
+# --- Loss --------------------------------------------------
+loss:
+ type: combined # "combined" | "focal" | "dice" | "lovasz"
+ focal_weight: 0.5 # weight of focal vs dice in combined loss
+ focal_alpha: 0.25
+ focal_gamma: 2.0
+ use_class_weights: true # re-weight by inverse class frequency
+
+# --- Optimiser --------------------------------------------
+optimizer:
+ learning_rate: 1.0e-4
+ weight_decay: 1.0e-4
+ min_lr: 1.0e-6
+
+# --- Schedule ---------------------------------------------
+schedule:
+ epochs: 100
+ warmup_epochs: 5
+ checkpoint_interval: 10 # save periodic snapshot every N epochs
+
+# --- Regularisation / Tricks ------------------------------
+training:
+ mixed_precision: true # AMP (CUDA only; ignored on CPU/MPS)
+ grad_clip: 1.0
+ use_ema: true
+ ema_decay: 0.99
+ early_stopping_patience: 15
+
+# --- Outputs ----------------------------------------------
+output:
+ save_dir: models
+ run_name: "" # auto-set to timestamp if empty
+
+# --- Normalisation stats ----------------------------------
+# Leave empty to use built-in Sentinel-2 L2A defaults.
+# Set to a JSON file path produced by Sentinel2Normalizer.save().
+normalizer_stats: ""
diff --git a/docs/ADEOLU MARY OSHADARE.docx b/docs/ADEOLU MARY OSHADARE.docx
deleted file mode 100644
index fa950cf..0000000
Binary files a/docs/ADEOLU MARY OSHADARE.docx and /dev/null differ
diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md
new file mode 100644
index 0000000..c337dd2
--- /dev/null
+++ b/docs/API_REFERENCE.md
@@ -0,0 +1,489 @@
+# ClimateVision API Reference
+
+This document provides a complete reference for the ClimateVision REST API.
+
+## Base URL
+
+```
+http://localhost:8000/api
+```
+
+## Authentication
+
+For organization-specific endpoints, use API key authentication:
+
+```bash
+curl -H "X-API-Key: your_api_key" http://localhost:8000/api/organizations/1/alerts
+```
+
+---
+
+## Core Endpoints
+
+### Health Check
+
+Check API status and available analysis types.
+
+```http
+GET /api/health
+```
+
+**Response:**
+```json
+{
+ "status": "ok",
+ "version": "0.2.0",
+ "analysis_types": ["deforestation", "ice_melting", "flooding"]
+}
+```
+
+---
+
+## Analysis Types
+
+### List Analysis Types
+
+Get all available analysis types.
+
+```http
+GET /api/analysis-types?enabled_only=true
+```
+
+**Response:**
+```json
+[
+ {
+ "name": "deforestation",
+ "display_name": "Deforestation Detection",
+ "description": "Monitor forest coverage and detect deforestation events",
+ "enabled": true,
+ "bands": ["B04", "B03", "B02", "B08"],
+ "classes": ["non_forest", "forest"]
+ },
+ {
+ "name": "ice_melting",
+ "display_name": "Arctic Ice Melting",
+ "description": "Monitor sea ice extent and melting patterns",
+ "enabled": true,
+ "bands": ["B02", "B03", "B04", "B11"],
+ "classes": ["open_water", "sea_ice", "land"]
+ }
+]
+```
+
+### Get Analysis Type Details
+
+```http
+GET /api/analysis-types/{type_name}
+```
+
+**Example:** `GET /api/analysis-types/deforestation`
+
+---
+
+## Prediction Endpoints
+
+### Run Prediction (JSON)
+
+Run analysis using bounding box and date range.
+
+```http
+POST /api/predict
+Content-Type: application/json
+
+{
+ "kind": "bbox",
+ "analysis_type": "deforestation",
+ "bbox": [-62.0, -3.1, -61.8, -2.9],
+ "start_date": "2024-01-01",
+ "end_date": "2024-12-31"
+}
+```
+
+**Response:**
+```json
+{
+ "run_id": 1,
+ "result": {
+ "analysis_type": "deforestation",
+ "region": {
+ "bbox": [-62.0, -3.1, -61.8, -2.9],
+ "date_range": "2024-01-01 to 2024-12-31"
+ },
+ "ndvi_stats": {
+ "NDVI_min": 0.123,
+ "NDVI_mean": 0.567,
+ "NDVI_max": 0.892
+ },
+ "inference": {
+ "image_size": [256, 256],
+ "forest_pixels": 45678,
+ "non_forest_pixels": 19858,
+ "forest_percentage": 69.72,
+ "mean_confidence": 0.87
+ }
+ }
+}
+```
+
+### Run Prediction (File Upload)
+
+Upload satellite imagery for analysis.
+
+```http
+POST /api/predict/upload
+Content-Type: multipart/form-data
+
+kind=upload
+analysis_type=ice_melting
+bbox=[-73, 60, -12, 84]
+start_date=2024-06-01
+end_date=2024-08-31
+file=@satellite_image.tif
+```
+
+**Response:**
+```json
+{
+ "run_id": 2,
+ "result": {
+ "analysis_type": "ice_melting",
+ "region": {
+ "bbox": [-73, 60, -12, 84]
+ },
+ "inference": {
+ "image_size": [512, 512],
+ "ice_pixels": 150000,
+ "water_pixels": 80000,
+ "land_pixels": 32144,
+ "ice_percentage": 65.2,
+ "ice_extent_km2": 45000.5,
+ "mean_confidence": 0.82
+ }
+ }
+}
+```
+
+---
+
+## Run History
+
+### List Runs
+
+Get analysis run history with optional filters.
+
+```http
+GET /api/runs?limit=50&status=completed&analysis_type=deforestation
+```
+
+**Query Parameters:**
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `limit` | int | Max results (default: 50, max: 200) |
+| `status` | string | Filter by status: pending, running, completed, failed |
+| `analysis_type` | string | Filter by analysis type |
+
+**Response:**
+```json
+[
+ {
+ "id": 1,
+ "kind": "bbox",
+ "status": "completed",
+ "analysis_type": "deforestation",
+ "bbox": "[-62.0, -3.1, -61.8, -2.9]",
+ "start_date": "2024-01-01",
+ "end_date": "2024-12-31",
+ "created_at": "2024-12-15T10:30:00Z",
+ "updated_at": "2024-12-15T10:30:45Z"
+ }
+]
+```
+
+### Get Run Details
+
+```http
+GET /api/runs/{run_id}
+```
+
+**Response:**
+```json
+{
+ "run": {
+ "id": 1,
+ "kind": "bbox",
+ "status": "completed",
+ "analysis_type": "deforestation",
+ "created_at": "2024-12-15T10:30:00Z"
+ },
+ "result": {
+ "id": 1,
+ "run_id": 1,
+ "payload": { ... },
+ "mask_path": null,
+ "created_at": "2024-12-15T10:30:45Z"
+ }
+}
+```
+
+---
+
+## Organization (NGO) Endpoints
+
+### Create Organization
+
+Register a new organization to receive alerts.
+
+```http
+POST /api/organizations
+Content-Type: application/json
+
+{
+ "name": "Rainforest Alliance",
+ "type": "ngo",
+ "description": "Protecting rainforests worldwide",
+ "contact_email": "alerts@rainforest.org",
+ "website_url": "https://rainforest.org"
+}
+```
+
+**Response:**
+```json
+{
+ "id": 1,
+ "name": "Rainforest Alliance",
+ "type": "ngo",
+ "api_key": "cv_abc123...",
+ "active": true,
+ "created_at": "2024-12-15T10:00:00Z"
+}
+```
+
+> **Important:** Save the `api_key` securely. It cannot be retrieved later.
+
+### List Organizations
+
+```http
+GET /api/organizations?type=ngo&limit=50
+```
+
+### Get Organization
+
+```http
+GET /api/organizations/{org_id}
+```
+
+---
+
+## Subscriptions
+
+Subscriptions allow organizations to monitor specific regions.
+
+### Create Subscription
+
+```http
+POST /api/organizations/{org_id}/subscriptions
+Content-Type: application/json
+
+{
+ "name": "Amazon Watch Zone 1",
+ "bbox": [-62.0, -3.1, -61.8, -2.9],
+ "analysis_types": ["deforestation", "wildfire"],
+ "alert_threshold": 5.0,
+ "notification_channel": "webhook",
+ "webhook_url": "https://example.org/webhooks/climate"
+}
+```
+
+**Response:**
+```json
+{
+ "id": 1,
+ "organization_id": 1,
+ "name": "Amazon Watch Zone 1",
+ "bbox": [-62.0, -3.1, -61.8, -2.9],
+ "analysis_types": ["deforestation", "wildfire"],
+ "alert_threshold": 5.0,
+ "notification_channel": "webhook",
+ "active": true,
+ "created_at": "2024-12-15T11:00:00Z"
+}
+```
+
+### List Subscriptions
+
+```http
+GET /api/organizations/{org_id}/subscriptions
+```
+
+---
+
+## Alerts
+
+### List Alerts
+
+```http
+GET /api/organizations/{org_id}/alerts?unacknowledged_only=true&limit=50
+```
+
+**Query Parameters:**
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `undelivered_only` | bool | Only undelivered alerts |
+| `unacknowledged_only` | bool | Only unacknowledged alerts |
+| `limit` | int | Max results |
+
+**Response:**
+```json
+[
+ {
+ "id": 1,
+ "organization_id": 1,
+ "alert_type": "deforestation_detected",
+ "severity": "high",
+ "title": "Deforestation Detected",
+ "message": "Forest loss detected: 7.5% reduction in coverage",
+ "delivered": true,
+ "acknowledged": false,
+ "created_at": "2024-12-15T12:00:00Z"
+ }
+]
+```
+
+### Create Alert
+
+```http
+POST /api/organizations/{org_id}/alerts
+Content-Type: application/json
+
+{
+ "alert_type": "deforestation_detected",
+ "severity": "high",
+ "title": "Deforestation Alert",
+ "message": "Significant forest loss detected in monitored region",
+ "subscription_id": 1,
+ "run_id": 5
+}
+```
+
+### Acknowledge Alert
+
+```http
+POST /api/alerts/{alert_id}/acknowledge
+Content-Type: application/json
+
+{
+ "acknowledged_by": "analyst@rainforest.org"
+}
+```
+
+### Mark Alert Delivered
+
+```http
+POST /api/alerts/{alert_id}/deliver
+```
+
+---
+
+## Error Responses
+
+All errors return a JSON response:
+
+```json
+{
+ "detail": "Error message here"
+}
+```
+
+**Common HTTP Status Codes:**
+| Code | Description |
+|------|-------------|
+| 400 | Bad Request - Invalid parameters |
+| 404 | Not Found - Resource doesn't exist |
+| 422 | Validation Error - Invalid request body |
+| 500 | Internal Server Error |
+
+---
+
+## Python SDK Example
+
+```python
+import requests
+
+API_BASE = "http://localhost:8000/api"
+
+# Run deforestation analysis
+response = requests.post(
+ f"{API_BASE}/predict",
+ json={
+ "kind": "bbox",
+ "analysis_type": "deforestation",
+ "bbox": [-62.0, -3.1, -61.8, -2.9],
+ "start_date": "2024-01-01",
+ "end_date": "2024-12-31"
+ }
+)
+result = response.json()
+print(f"Forest coverage: {result['result']['inference']['forest_percentage']}%")
+
+# Create an organization
+org_response = requests.post(
+ f"{API_BASE}/organizations",
+ json={
+ "name": "My NGO",
+ "type": "ngo",
+ "contact_email": "contact@myngo.org"
+ }
+)
+org = org_response.json()
+api_key = org["api_key"] # Save this!
+
+# Create a subscription
+sub_response = requests.post(
+ f"{API_BASE}/organizations/{org['id']}/subscriptions",
+ json={
+ "name": "Amazon Region",
+ "bbox": [-70, -10, -50, 5],
+ "analysis_types": ["deforestation"],
+ "alert_threshold": 5.0
+ }
+)
+```
+
+---
+
+## JavaScript/TypeScript Example
+
+```typescript
+const API_BASE = 'http://localhost:8000/api';
+
+// Run ice melting analysis
+async function analyzeIce() {
+ const response = await fetch(`${API_BASE}/predict`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ kind: 'bbox',
+ analysis_type: 'ice_melting',
+ bbox: [-73, 60, -12, 84],
+ start_date: '2024-06-01',
+ end_date: '2024-08-31'
+ })
+ });
+
+ const { run_id, result } = await response.json();
+ console.log(`Ice extent: ${result.inference.ice_percentage}%`);
+ return result;
+}
+
+// List organization alerts
+async function getAlerts(orgId: number, apiKey: string) {
+ const response = await fetch(
+ `${API_BASE}/organizations/${orgId}/alerts?unacknowledged_only=true`,
+ {
+ headers: { 'X-API-Key': apiKey }
+ }
+ );
+ return response.json();
+}
+```
diff --git a/docs/Francis Umo.docx b/docs/Francis Umo.docx
deleted file mode 100644
index d72efdc..0000000
Binary files a/docs/Francis Umo.docx and /dev/null differ
diff --git a/docs/OLUFEMI TAIWO.docx b/docs/OLUFEMI TAIWO.docx
deleted file mode 100644
index 54a5c2d..0000000
Binary files a/docs/OLUFEMI TAIWO.docx and /dev/null differ
diff --git a/frontend/.env.example b/frontend/.env.example
new file mode 100644
index 0000000..ffbb571
--- /dev/null
+++ b/frontend/.env.example
@@ -0,0 +1,4 @@
+# API base URL for frontend requests
+# Leave empty when using Vite dev server (proxy handles /api -> backend)
+# Set to http://127.0.0.1:8000 when serving built app separately
+VITE_API_BASE_URL=
diff --git a/frontend/index.html b/frontend/index.html
index aeb5f03..e8352d0 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,6 +4,9 @@
ClimateVision
+
+
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 786a3ae..81af47e 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,8 +8,13 @@
"name": "climatevision-frontend",
"version": "0.1.0",
"dependencies": {
+ "@react-google-maps/api": "^2.20.8",
+ "framer-motion": "^12.35.0",
+ "lucide-react": "^0.577.0",
"react": "^18.2.0",
- "react-dom": "^18.2.0"
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^7.13.1",
+ "recharts": "^3.7.0"
},
"devDependencies": {
"@types/react": "^18.2.55",
@@ -708,6 +713,22 @@
"node": ">=12"
}
},
+ "node_modules/@googlemaps/js-api-loader": {
+ "version": "1.16.8",
+ "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.8.tgz",
+ "integrity": "sha512-CROqqwfKotdO6EBjZO/gQGVTbeDps5V7Mt9+8+5Q+jTg5CRMi3Ii/L9PmV3USROrt2uWxtGzJHORmByxyo9pSQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@googlemaps/markerclusterer": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.5.3.tgz",
+ "integrity": "sha512-x7lX0R5yYOoiNectr10wLgCBasNcXFHiADIBdmn7jQllF2B5ENQw5XtZK+hIw4xnV0Df0xhN4LN98XqA5jaiOw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "supercluster": "^8.0.1"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -796,6 +817,72 @@
"node": ">= 8"
}
},
+ "node_modules/@react-google-maps/api": {
+ "version": "2.20.8",
+ "resolved": "https://registry.npmjs.org/@react-google-maps/api/-/api-2.20.8.tgz",
+ "integrity": "sha512-wtLYFtCGXK3qbIz1H5to3JxbosPnKsvjDKhqGylXUb859EskhzR7OpuNt0LqdLarXUtZCJTKzPn3BNaekNIahg==",
+ "license": "MIT",
+ "dependencies": {
+ "@googlemaps/js-api-loader": "1.16.8",
+ "@googlemaps/markerclusterer": "2.5.3",
+ "@react-google-maps/infobox": "2.20.0",
+ "@react-google-maps/marker-clusterer": "2.20.0",
+ "@types/google.maps": "3.58.1",
+ "invariant": "2.2.4"
+ },
+ "peerDependencies": {
+ "react": "^16.8 || ^17 || ^18 || ^19",
+ "react-dom": "^16.8 || ^17 || ^18 || ^19"
+ }
+ },
+ "node_modules/@react-google-maps/infobox": {
+ "version": "2.20.0",
+ "resolved": "https://registry.npmjs.org/@react-google-maps/infobox/-/infobox-2.20.0.tgz",
+ "integrity": "sha512-03PJHjohhaVLkX6+NHhlr8CIlvUxWaXhryqDjyaZ8iIqqix/nV8GFdz9O3m5OsjtxtNho09F/15j14yV0nuyLQ==",
+ "license": "MIT"
+ },
+ "node_modules/@react-google-maps/marker-clusterer": {
+ "version": "2.20.0",
+ "resolved": "https://registry.npmjs.org/@react-google-maps/marker-clusterer/-/marker-clusterer-2.20.0.tgz",
+ "integrity": "sha512-tieX9Va5w1yP88vMgfH1pHTacDQ9TgDTjox3tLlisKDXRQWdjw+QeVVghhf5XqqIxXHgPdcGwBvKY6UP+SIvLw==",
+ "license": "MIT"
+ },
+ "node_modules/@reduxjs/toolkit": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
+ "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@standard-schema/utils": "^0.3.0",
+ "immer": "^11.0.0",
+ "redux": "^5.0.1",
+ "redux-thunk": "^3.1.0",
+ "reselect": "^5.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-redux": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@reduxjs/toolkit/node_modules/immer": {
+ "version": "11.1.4",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
+ "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1153,6 +1240,18 @@
"win32"
]
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1198,6 +1297,69 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1205,18 +1367,24 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/google.maps": {
+ "version": "3.58.1",
+ "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz",
+ "integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==",
+ "license": "MIT"
+ },
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -1233,6 +1401,12 @@
"@types/react": "^18.0.0"
}
},
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1458,6 +1632,15 @@
"node": ">= 6"
}
},
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -1475,6 +1658,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -1492,9 +1688,130 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1513,6 +1830,12 @@
}
}
},
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -1534,6 +1857,16 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/es-toolkit": {
+ "version": "1.45.1",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
+ "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
+ "license": "MIT",
+ "workspaces": [
+ "docs",
+ "benchmarks"
+ ]
+ },
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1583,6 +1916,18 @@
"node": ">=6"
}
},
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "license": "MIT"
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -1650,6 +1995,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
+ "node_modules/framer-motion": {
+ "version": "12.35.0",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.35.0.tgz",
+ "integrity": "sha512-w8hghCMQ4oq10j6aZh3U2yeEQv5K69O/seDI/41PK4HtgkLrcBovUNc0ayBC3UyyU7V1mrY2yLzvYdWJX9pGZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.35.0",
+ "motion-utils": "^12.29.2",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1711,6 +2083,34 @@
"node": ">= 0.4"
}
},
+ "node_modules/immer": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
+ "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/invariant": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
+ "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.0.0"
+ }
+ },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -1815,6 +2215,12 @@
"node": ">=6"
}
},
+ "node_modules/kdbush": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
+ "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
+ "license": "ISC"
+ },
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -1857,6 +2263,15 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucide-react": {
+ "version": "0.577.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz",
+ "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -1881,6 +2296,21 @@
"node": ">=8.6"
}
},
+ "node_modules/motion-dom": {
+ "version": "12.35.0",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.35.0.tgz",
+ "integrity": "sha512-FFMLEnIejK/zDABn+vqGVAUN4T0+3fw+cVAY8MMT65yR+j5uMuvWdd4npACWhh94OVWQs79CrBBuwOwGRZAQiA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.29.2"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.29.2",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
+ "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2212,6 +2642,36 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-is": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
+ "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/react-redux": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
+ "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.2.25 || ^19",
+ "react": "^18.0 || ^19",
+ "redux": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -2222,6 +2682,44 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-router": {
+ "version": "7.13.1",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
+ "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.13.1",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
+ "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.13.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -2245,6 +2743,57 @@
"node": ">=8.10.0"
}
},
+ "node_modules/recharts": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
+ "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
+ "license": "MIT",
+ "workspaces": [
+ "www"
+ ],
+ "dependencies": {
+ "@reduxjs/toolkit": "1.x.x || 2.x.x",
+ "clsx": "^2.1.1",
+ "decimal.js-light": "^2.5.1",
+ "es-toolkit": "^1.39.3",
+ "eventemitter3": "^5.0.1",
+ "immer": "^10.1.1",
+ "react-redux": "8.x.x || 9.x.x",
+ "reselect": "5.1.1",
+ "tiny-invariant": "^1.3.3",
+ "use-sync-external-store": "^1.2.2",
+ "victory-vendor": "^37.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
+ "license": "MIT"
+ },
+ "node_modules/redux-thunk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "redux": "^5.0.0"
+ }
+ },
+ "node_modules/reselect": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
+ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
+ "license": "MIT"
+ },
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -2365,6 +2914,12 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2398,6 +2953,15 @@
"node": ">=16 || 14 >=14.17"
}
},
+ "node_modules/supercluster": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
+ "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
+ "license": "ISC",
+ "dependencies": {
+ "kdbush": "^4.0.2"
+ }
+ },
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -2472,6 +3036,12 @@
"node": ">=0.8"
}
},
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2540,6 +3110,12 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2585,6 +3161,15 @@
"browserslist": ">= 4.21.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -2592,6 +3177,28 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/victory-vendor": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
+ "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index cc67945..a976d4d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -9,8 +9,13 @@
"preview": "vite preview"
},
"dependencies": {
+ "@react-google-maps/api": "^2.20.8",
+ "framer-motion": "^12.35.0",
+ "lucide-react": "^0.577.0",
"react": "^18.2.0",
- "react-dom": "^18.2.0"
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^7.13.1",
+ "recharts": "^3.7.0"
},
"devDependencies": {
"@types/react": "^18.2.55",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index b4f5a41..93dff27 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,408 +1,4 @@
-import { useEffect, useMemo, useState } from 'react'
-import { getRun, health, listRuns, predictJson, predictUpload } from './api'
-
-type Tab = 'bbox' | 'upload' | 'runs'
-
-type Toast = { type: 'success' | 'error'; message: string }
-
-function cx(...parts: Array) {
- return parts.filter(Boolean).join(' ')
-}
-
-function Card(props: { title: string; children: React.ReactNode; right?: React.ReactNode }) {
- return (
-
-
-
{props.title}
- {props.right}
-
-
{props.children}
-
- )
-}
-
-function Field(props: {
- label: string
- hint?: string
- children: React.ReactNode
-}) {
- return (
-
- )
-}
-
-function Button(props: {
- children: React.ReactNode
- onClick?: () => void
- type?: 'button' | 'submit'
- disabled?: boolean
- variant?: 'primary' | 'ghost'
-}) {
- const variant = props.variant ?? 'primary'
- return (
-
- )
-}
-
-function Input(props: React.InputHTMLAttributes) {
- return (
-
- )
-}
-
-function Textarea(props: React.TextareaHTMLAttributes) {
- return (
-
- )
-}
-
-export default function App() {
- const [tab, setTab] = useState('bbox')
- const [apiOk, setApiOk] = useState(null)
-
- const [toast, setToast] = useState(null)
- const showToast = (t: Toast) => {
- setToast(t)
- window.setTimeout(() => setToast(null), 3500)
- }
-
- const [bboxText, setBboxText] = useState('[-62.0, -3.1, -61.8, -2.9]')
- const [startDate, setStartDate] = useState('2024-01-01')
- const [endDate, setEndDate] = useState('2024-12-31')
-
- const [uploadFile, setUploadFile] = useState(null)
-
- const [busy, setBusy] = useState(false)
- const [result, setResult] = useState(null)
-
- const [runs, setRuns] = useState([])
- const [selectedRunId, setSelectedRunId] = useState(null)
- const [selectedRun, setSelectedRun] = useState(null)
-
- const parsedBBox = useMemo(() => {
- try {
- const v = JSON.parse(bboxText)
- if (Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === 'number')) return v as number[]
- return null
- } catch {
- return null
- }
- }, [bboxText])
-
- useEffect(() => {
- health()
- .then(() => setApiOk(true))
- .catch(() => setApiOk(false))
- }, [])
-
- useEffect(() => {
- if (tab !== 'runs') return
- listRuns()
- .then(setRuns)
- .catch((e) => showToast({ type: 'error', message: String(e) }))
- }, [tab])
-
- useEffect(() => {
- if (selectedRunId == null) return
- getRun(selectedRunId)
- .then(setSelectedRun)
- .catch((e) => showToast({ type: 'error', message: String(e) }))
- }, [selectedRunId])
-
- const runBBoxPredict = async () => {
- if (!parsedBBox) {
- showToast({ type: 'error', message: 'BBox must be valid JSON: [minLon, minLat, maxLon, maxLat]' })
- return
- }
-
- setBusy(true)
- setResult(null)
- try {
- const res = await predictJson({
- kind: 'bbox',
- bbox: parsedBBox,
- start_date: startDate,
- end_date: endDate,
- })
- setResult(res)
- showToast({ type: 'success', message: `Run created (#${res.run_id})` })
- } catch (e) {
- showToast({ type: 'error', message: String(e) })
- } finally {
- setBusy(false)
- }
- }
-
- const runUploadPredict = async () => {
- if (!uploadFile) {
- showToast({ type: 'error', message: 'Choose a file first.' })
- return
- }
-
- setBusy(true)
- setResult(null)
- try {
- const res = await predictUpload({
- file: uploadFile,
- kind: 'upload',
- bbox: parsedBBox ?? undefined,
- start_date: startDate,
- end_date: endDate,
- })
- setResult(res)
- showToast({ type: 'success', message: `Upload processed (#${res.run_id})` })
- } catch (e) {
- showToast({ type: 'error', message: String(e) })
- } finally {
- setBusy(false)
- }
- }
-
- return (
-
-
-
-
-
-
- {tab === 'bbox' ? (
-
POST /api/predict}
- >
-
-
-
-
-
-
- setStartDate(e.target.value)} />
-
-
- setEndDate(e.target.value)} />
-
-
-
-
-
-
-
-
- ) : null}
-
- {tab === 'upload' ? (
-
POST /api/predict/upload}
- >
-
-
- ) : null}
-
- {tab === 'runs' ? (
-
-
-
-
Latest runs
-
-
-
-
- {runs.length === 0 ? (
-
No runs yet.
- ) : (
- runs.map((r) => (
-
- ))
- )}
-
-
- {selectedRun ? (
-
-
Run details
-
- {JSON.stringify(selectedRun, null, 2)}
-
-
- ) : null}
-
-
- ) : null}
-
-
-
-
- {result ? (
-
- {JSON.stringify(result, null, 2)}
-
- ) : (
- Run a prediction to see the response here.
- )}
-
-
-
-
Notes
-
- This UI calls:
-
-
GET /api/health
-
POST /api/predict
-
POST /api/predict/upload
-
GET /api/runs
-
GET /api/runs/:id
-
-
-
-
-
-
-
- {toast ? (
-
- ) : null}
-
- )
-}
+// App.tsx is no longer the entry point.
+// Routing is handled in main.tsx via React Router.
+// This file is kept for legacy imports only.
+export default function App() { return null }
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 31f2ef0..e72eb07 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -1,35 +1,190 @@
-export type PredictJsonRequest = {
+/**
+ * ClimateVision API Client
+ *
+ * TypeScript client for interacting with the ClimateVision REST API.
+ */
+
+// ===== Types =====
+
+export type AnalysisType = 'deforestation' | 'ice_melting' | 'flooding' | 'drought' | 'wildfire'
+
+export type RunStatus = 'pending' | 'running' | 'completed' | 'failed'
+
+export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical'
+
+export interface HealthResponse {
+ status: string
+ version?: string
+ analysis_types?: AnalysisType[]
+}
+
+export interface PredictJsonRequest {
kind?: string
+ analysis_type?: AnalysisType
bbox?: number[]
start_date?: string
end_date?: string
}
+export interface PredictUploadRequest {
+ file: File
+ kind?: string
+ analysis_type?: AnalysisType
+ bbox?: number[]
+ start_date?: string
+ end_date?: string
+}
+
+export interface Run {
+ id: number
+ kind: string
+ status: RunStatus
+ analysis_type: AnalysisType
+ bbox?: string
+ start_date?: string
+ end_date?: string
+ created_at: string
+ updated_at: string
+}
+
+export interface RunResult {
+ id: number
+ run_id: number
+ payload: Record
+ mask_path?: string
+ created_at: string
+}
+
+export interface RunWithResult {
+ run: Run
+ result: RunResult | null
+}
+
+export interface Organization {
+ id: number
+ name: string
+ type: string
+ description?: string
+ contact_email?: string
+ website_url?: string
+ active: boolean
+ created_at: string
+}
+
+export interface OrganizationWithKey extends Organization {
+ api_key: string
+}
+
+export interface CreateOrganizationRequest {
+ name: string
+ type?: string
+ description?: string
+ contact_email?: string
+ website_url?: string
+ regions_of_interest?: string[]
+}
+
+export interface Subscription {
+ id: number
+ organization_id: number
+ name?: string
+ bbox: number[]
+ analysis_types: AnalysisType[]
+ alert_threshold: number
+ notification_channel: string
+ active: boolean
+ created_at: string
+}
+
+export interface CreateSubscriptionRequest {
+ name?: string
+ description?: string
+ bbox: number[]
+ analysis_types?: AnalysisType[]
+ alert_threshold?: number
+ notification_channel?: string
+ webhook_url?: string
+}
+
+export interface Alert {
+ id: number
+ organization_id: number
+ alert_type: string
+ severity: AlertSeverity
+ title: string
+ message: string
+ delivered: boolean
+ acknowledged: boolean
+ created_at: string
+}
+
+export interface AnalysisTypeInfo {
+ name: AnalysisType
+ display_name: string
+ description: string
+ enabled: boolean
+ bands: string[]
+ classes: string[]
+}
+
+// ===== Configuration =====
+
const DEFAULT_BASE_URL = ''
export function getApiBaseUrl(): string {
return import.meta.env.VITE_API_BASE_URL ?? DEFAULT_BASE_URL
}
-export async function health(): Promise<{ status: string }> {
+// ===== Core Endpoints =====
+
+export async function health(): Promise {
const res = await fetch(`${getApiBaseUrl()}/api/health`)
if (!res.ok) throw new Error('Health check failed')
return res.json()
}
-export async function listRuns(): Promise {
- const res = await fetch(`${getApiBaseUrl()}/api/runs`)
+export async function listAnalysisTypes(enabledOnly = true): Promise {
+ const res = await fetch(`${getApiBaseUrl()}/api/analysis-types?enabled_only=${enabledOnly}`)
+ if (!res.ok) throw new Error('Failed to load analysis types')
+ return res.json()
+}
+
+export async function getAnalysisType(name: string): Promise {
+ const res = await fetch(`${getApiBaseUrl()}/api/analysis-types/${name}`)
+ if (!res.ok) throw new Error('Analysis type not found')
+ return res.json()
+}
+
+// ===== Run Endpoints =====
+
+export async function listRuns(options?: {
+ limit?: number
+ status?: RunStatus
+ analysis_type?: AnalysisType
+}): Promise {
+ const params = new URLSearchParams()
+ if (options?.limit) params.set('limit', String(options.limit))
+ if (options?.status) params.set('status', options.status)
+ if (options?.analysis_type) params.set('analysis_type', options.analysis_type)
+
+ const url = `${getApiBaseUrl()}/api/runs${params.toString() ? `?${params}` : ''}`
+ const res = await fetch(url)
if (!res.ok) throw new Error('Failed to load runs')
return res.json()
}
-export async function getRun(runId: number): Promise {
+export async function getRun(runId: number): Promise {
const res = await fetch(`${getApiBaseUrl()}/api/runs/${runId}`)
if (!res.ok) throw new Error('Failed to load run')
return res.json()
}
-export async function predictJson(payload: PredictJsonRequest): Promise {
+// ===== Prediction Endpoints =====
+
+export async function predictJson(payload: PredictJsonRequest): Promise<{
+ run_id: number
+ result: Record
+}> {
const res = await fetch(`${getApiBaseUrl()}/api/predict`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -39,15 +194,13 @@ export async function predictJson(payload: PredictJsonRequest): Promise {
return res.json()
}
-export async function predictUpload(args: {
- file: File
- kind?: string
- bbox?: number[]
- start_date?: string
- end_date?: string
-}): Promise {
+export async function predictUpload(args: PredictUploadRequest): Promise<{
+ run_id: number
+ result: Record
+}> {
const form = new FormData()
form.set('kind', args.kind ?? 'upload')
+ form.set('analysis_type', args.analysis_type ?? 'deforestation')
if (args.bbox) form.set('bbox', JSON.stringify(args.bbox))
if (args.start_date) form.set('start_date', args.start_date)
if (args.end_date) form.set('end_date', args.end_date)
@@ -60,3 +213,92 @@ export async function predictUpload(args: {
if (!res.ok) throw new Error('Upload prediction failed')
return res.json()
}
+
+// ===== Organization Endpoints =====
+
+export async function createOrganization(
+ data: CreateOrganizationRequest
+): Promise {
+ const res = await fetch(`${getApiBaseUrl()}/api/organizations`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ })
+ if (!res.ok) throw new Error('Failed to create organization')
+ return res.json()
+}
+
+export async function listOrganizations(options?: {
+ type?: string
+ limit?: number
+}): Promise {
+ const params = new URLSearchParams()
+ if (options?.type) params.set('type', options.type)
+ if (options?.limit) params.set('limit', String(options.limit))
+
+ const url = `${getApiBaseUrl()}/api/organizations${params.toString() ? `?${params}` : ''}`
+ const res = await fetch(url)
+ if (!res.ok) throw new Error('Failed to load organizations')
+ return res.json()
+}
+
+export async function getOrganization(orgId: number): Promise {
+ const res = await fetch(`${getApiBaseUrl()}/api/organizations/${orgId}`)
+ if (!res.ok) throw new Error('Organization not found')
+ return res.json()
+}
+
+// ===== Subscription Endpoints =====
+
+export async function createSubscription(
+ orgId: number,
+ data: CreateSubscriptionRequest
+): Promise {
+ const res = await fetch(`${getApiBaseUrl()}/api/organizations/${orgId}/subscriptions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ })
+ if (!res.ok) throw new Error('Failed to create subscription')
+ return res.json()
+}
+
+export async function listSubscriptions(orgId: number): Promise {
+ const res = await fetch(`${getApiBaseUrl()}/api/organizations/${orgId}/subscriptions`)
+ if (!res.ok) throw new Error('Failed to load subscriptions')
+ return res.json()
+}
+
+// ===== Alert Endpoints =====
+
+export async function listAlerts(
+ orgId: number,
+ options?: {
+ undelivered_only?: boolean
+ unacknowledged_only?: boolean
+ limit?: number
+ }
+): Promise {
+ const params = new URLSearchParams()
+ if (options?.undelivered_only) params.set('undelivered_only', 'true')
+ if (options?.unacknowledged_only) params.set('unacknowledged_only', 'true')
+ if (options?.limit) params.set('limit', String(options.limit))
+
+ const url = `${getApiBaseUrl()}/api/organizations/${orgId}/alerts${params.toString() ? `?${params}` : ''}`
+ const res = await fetch(url)
+ if (!res.ok) throw new Error('Failed to load alerts')
+ return res.json()
+}
+
+export async function acknowledgeAlert(
+ alertId: number,
+ acknowledgedBy?: string
+): Promise<{ success: boolean; alert_id: number }> {
+ const res = await fetch(`${getApiBaseUrl()}/api/alerts/${alertId}/acknowledge`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ acknowledged_by: acknowledgedBy }),
+ })
+ if (!res.ok) throw new Error('Failed to acknowledge alert')
+ return res.json()
+}
diff --git a/frontend/src/components/Map/MapBBoxPicker.tsx b/frontend/src/components/Map/MapBBoxPicker.tsx
new file mode 100644
index 0000000..418f250
--- /dev/null
+++ b/frontend/src/components/Map/MapBBoxPicker.tsx
@@ -0,0 +1,379 @@
+import { useEffect, useRef, useState, useCallback } from 'react'
+import { MapPin, Trash2, Maximize2, Pencil, Hand } from 'lucide-react'
+
+interface MapBBoxPickerProps {
+ value: number[] | null
+ onChange: (bbox: number[] | null) => void
+ apiKey: string
+}
+
+declare global {
+ interface Window {
+ google: typeof google
+ initGoogleMaps?: () => void
+ _googleMapsLoaded?: boolean
+ }
+}
+
+function loadGoogleMapsScript(apiKey: string): Promise {
+ if (window._googleMapsLoaded) return Promise.resolve()
+ if (!apiKey || apiKey === 'YOUR_GOOGLE_MAPS_API_KEY_HERE') return Promise.resolve()
+ return new Promise((resolve, reject) => {
+ if (document.querySelector('script[data-gmaps]')) {
+ const check = setInterval(() => {
+ if (window.google?.maps) {
+ window._googleMapsLoaded = true
+ clearInterval(check)
+ resolve()
+ }
+ }, 100)
+ return
+ }
+ window.initGoogleMaps = () => {
+ window._googleMapsLoaded = true
+ resolve()
+ }
+ const script = document.createElement('script')
+ script.setAttribute('data-gmaps', '1')
+ script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places&callback=initGoogleMaps&loading=async`
+ script.async = true
+ script.defer = true
+ script.onerror = () => {
+ window._googleMapsLoaded = false
+ reject(new Error('Failed to load Google Maps script'))
+ }
+ document.head.appendChild(script)
+ })
+}
+
+export function MapBBoxPicker({ value, onChange, apiKey }: MapBBoxPickerProps) {
+ const mapRef = useRef(null)
+ const searchRef = useRef(null)
+ const mapInstance = useRef(null)
+ const rectangle = useRef(null)
+
+ const [mapType, setMapType] = useState<'satellite' | 'roadmap' | 'hybrid'>('satellite')
+ const [mapsReady, setMapsReady] = useState(false)
+ const [noKey, setNoKey] = useState(false)
+ const [drawMode, setDrawMode] = useState(false)
+
+ const updateBBoxFromRectangle = useCallback((rect: google.maps.Rectangle) => {
+ const bounds = rect.getBounds()
+ if (!bounds) return
+ const sw = bounds.getSouthWest()
+ const ne = bounds.getNorthEast()
+ onChange([sw.lng(), sw.lat(), ne.lng(), ne.lat()])
+ }, [onChange])
+
+ const clearRectangle = useCallback(() => {
+ rectangle.current?.setMap(null)
+ rectangle.current = null
+ onChange(null)
+ }, [onChange])
+
+ const useCurrentView = useCallback(() => {
+ if (!mapInstance.current) return
+ const bounds = mapInstance.current.getBounds()
+ if (!bounds) return
+ const sw = bounds.getSouthWest()
+ const ne = bounds.getNorthEast()
+ onChange([sw.lng(), sw.lat(), ne.lng(), ne.lat()])
+
+ if (rectangle.current) {
+ rectangle.current.setBounds(bounds)
+ } else {
+ rectangle.current = new google.maps.Rectangle({
+ map: mapInstance.current,
+ bounds,
+ strokeColor: '#22c55e',
+ strokeOpacity: 0.9,
+ strokeWeight: 2,
+ fillColor: '#22c55e',
+ fillOpacity: 0.12,
+ editable: true,
+ draggable: true,
+ })
+ rectangle.current.addListener('bounds_changed', () => {
+ if (rectangle.current) updateBBoxFromRectangle(rectangle.current)
+ })
+ }
+ }, [onChange, updateBBoxFromRectangle])
+
+ // Apply draw mode to map (cursor + draggability)
+ useEffect(() => {
+ if (!mapInstance.current) return
+ if (drawMode) {
+ mapInstance.current.setOptions({ draggable: false, gestureHandling: 'none' })
+ if (mapRef.current) mapRef.current.style.cursor = 'crosshair'
+ } else {
+ mapInstance.current.setOptions({ draggable: true, gestureHandling: 'auto' })
+ if (mapRef.current) mapRef.current.style.cursor = ''
+ }
+ }, [drawMode])
+
+ useEffect(() => {
+ if (!apiKey || apiKey === 'YOUR_GOOGLE_MAPS_API_KEY_HERE') {
+ setNoKey(true)
+ return
+ }
+ loadGoogleMapsScript(apiKey)
+ .then(() => setMapsReady(true))
+ .catch(() => setNoKey(true))
+ }, [apiKey])
+
+ useEffect(() => {
+ if (!mapsReady || !mapRef.current) return
+
+ const map = new google.maps.Map(mapRef.current, {
+ center: { lat: 20, lng: 0 },
+ zoom: 2,
+ mapTypeId: mapType,
+ disableDefaultUI: false,
+ mapTypeControl: false,
+ streetViewControl: false,
+ fullscreenControl: false,
+ zoomControl: true,
+ styles: [
+ { elementType: 'geometry', stylers: [{ color: '#1a2e20' }] },
+ { elementType: 'labels.text.fill', stylers: [{ color: '#86efac' }] },
+ { elementType: 'labels.text.stroke', stylers: [{ color: '#0a0f0d' }] },
+ { featureType: 'water', elementType: 'geometry', stylers: [{ color: '#0c2340' }] },
+ { featureType: 'road', stylers: [{ visibility: 'simplified' }] },
+ ],
+ })
+ mapInstance.current = map
+
+ // Places autocomplete
+ if (searchRef.current) {
+ const autocomplete = new google.maps.places.Autocomplete(searchRef.current)
+ autocomplete.addListener('place_changed', () => {
+ const place = autocomplete.getPlace()
+ if (place.geometry?.viewport) {
+ map.fitBounds(place.geometry.viewport)
+ } else if (place.geometry?.location) {
+ map.setCenter(place.geometry.location)
+ map.setZoom(10)
+ }
+ })
+ }
+
+ // Drawing via mouse — only active in draw mode
+ // We use a ref-accessed flag so the listener always sees the latest value
+ const drawModeRef = { current: false }
+
+ let startPoint: google.maps.LatLng | null = null
+ let isDrawing = false
+
+ const mousedownListener = map.addListener('mousedown', (e: google.maps.MapMouseEvent) => {
+ if (!drawModeRef.current || !e.latLng) return
+ startPoint = e.latLng
+ isDrawing = true
+
+ // Clear existing rectangle when starting a new draw
+ if (rectangle.current) {
+ rectangle.current.setMap(null)
+ rectangle.current = null
+ }
+ })
+
+ const mousemoveListener = map.addListener('mousemove', (e: google.maps.MapMouseEvent) => {
+ if (!isDrawing || !startPoint || !e.latLng) return
+ const bounds = new google.maps.LatLngBounds(startPoint, e.latLng)
+
+ if (!rectangle.current) {
+ rectangle.current = new google.maps.Rectangle({
+ map,
+ bounds,
+ strokeColor: '#22c55e',
+ strokeOpacity: 0.9,
+ strokeWeight: 2,
+ fillColor: '#22c55e',
+ fillOpacity: 0.12,
+ editable: false,
+ draggable: false,
+ })
+ } else {
+ rectangle.current.setBounds(bounds)
+ }
+ })
+
+ const mouseupListener = map.addListener('mouseup', () => {
+ if (!isDrawing) return
+ if (rectangle.current) {
+ // Make editable/draggable now that drawing is done
+ rectangle.current.setOptions({ editable: true, draggable: true })
+ rectangle.current.addListener('bounds_changed', () => {
+ if (rectangle.current) updateBBoxFromRectangle(rectangle.current)
+ })
+ updateBBoxFromRectangle(rectangle.current)
+ }
+ isDrawing = false
+ startPoint = null
+ // Exit draw mode after completing a box
+ drawModeRef.current = false
+ setDrawMode(false)
+ })
+
+ // Keep drawModeRef in sync with React state
+ const syncDrawMode = (active: boolean) => {
+ drawModeRef.current = active
+ }
+
+ // Expose sync function via map data so the drawMode effect can call it
+ ;(map as unknown as { _syncDrawMode?: (v: boolean) => void })._syncDrawMode = syncDrawMode
+
+ return () => {
+ google.maps.event.removeListener(mousedownListener)
+ google.maps.event.removeListener(mousemoveListener)
+ google.maps.event.removeListener(mouseupListener)
+ }
+ }, [mapsReady, updateBBoxFromRectangle])
+
+ // Keep the map's internal drawModeRef in sync with React drawMode state
+ useEffect(() => {
+ if (!mapInstance.current) return
+ const map = mapInstance.current as unknown as { _syncDrawMode?: (v: boolean) => void }
+ map._syncDrawMode?.(drawMode)
+ }, [drawMode])
+
+ // Sync map type
+ useEffect(() => {
+ if (mapInstance.current) {
+ mapInstance.current.setMapTypeId(mapType)
+ }
+ }, [mapType])
+
+ if (noKey) {
+ return (
+
+
+
+ Add your Google Maps API key in .env to enable
+ the interactive map picker.
+
+
VITE_GOOGLE_MAPS_API_KEY=your_key_here
+
+ )
+ }
+
+ if (!mapsReady) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {/* Search bar */}
+
+
+
+
+ {/* Map */}
+
+
+
+ {/* Draw / Pan mode toggle — top left */}
+
+
+
+
+
+ {/* Map type toggle — top right */}
+
+ {(['satellite', 'roadmap', 'hybrid'] as const).map((t) => (
+
+ ))}
+
+
+ {/* Instruction overlay */}
+ {drawMode && (
+
+ Click and drag to draw a bounding box
+
+ )}
+ {!drawMode && !value && (
+
+ Switch to Draw mode to select a region
+
+ )}
+
+
+ {/* Controls */}
+
+ {/* Coordinate chips */}
+ {value ? (
+
+ {['minLon', 'minLat', 'maxLon', 'maxLat'].map((label, i) => (
+
+ {value[i]?.toFixed(4)}
+
+ ))}
+
+ ) : (
+
No region selected
+ )}
+
+
+
+ {value && (
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/components/Map/RegionMap.tsx b/frontend/src/components/Map/RegionMap.tsx
new file mode 100644
index 0000000..29b2f26
--- /dev/null
+++ b/frontend/src/components/Map/RegionMap.tsx
@@ -0,0 +1,357 @@
+/**
+ * RegionMap Component
+ *
+ * Interactive map for displaying and selecting geographic regions.
+ * Uses a simple SVG-based world map for lightweight implementation.
+ * Can be upgraded to Leaflet for more advanced features.
+ */
+
+import { useState, useRef, useCallback, useEffect } from 'react'
+
+function cx(...parts: Array) {
+ return parts.filter(Boolean).join(' ')
+}
+
+// Type for bounding box
+export type BBox = [number, number, number, number] // [minLon, minLat, maxLon, maxLat]
+
+export interface RegionMapProps {
+ bbox?: BBox
+ onBBoxChange?: (bbox: BBox) => void
+ highlightRegions?: Array<{
+ bbox: BBox
+ color?: string
+ label?: string
+ }>
+ className?: string
+ interactive?: boolean
+ showGrid?: boolean
+ showLabels?: boolean
+}
+
+// Simple world map projection (Plate Carrée)
+const MAP_WIDTH = 360
+const MAP_HEIGHT = 180
+
+// Convert lat/lon to SVG coordinates
+function lonToX(lon: number): number {
+ return ((lon + 180) / 360) * MAP_WIDTH
+}
+
+function latToY(lat: number): number {
+ return ((90 - lat) / 180) * MAP_HEIGHT
+}
+
+// Convert SVG coordinates to lat/lon
+function xToLon(x: number): number {
+ return (x / MAP_WIDTH) * 360 - 180
+}
+
+function yToLat(y: number): number {
+ return 90 - (y / MAP_HEIGHT) * 180
+}
+
+// Simplified world continent outlines (rough approximation)
+const CONTINENTS = [
+ // North America
+ 'M50,30 L70,25 L90,30 L100,40 L110,50 L100,70 L80,70 L60,60 L50,50 Z',
+ // South America
+ 'M80,80 L90,75 L100,85 L95,110 L85,120 L75,115 L70,100 Z',
+ // Europe
+ 'M165,30 L185,25 L200,30 L195,40 L180,45 L165,40 Z',
+ // Africa
+ 'M165,55 L190,50 L200,60 L195,90 L175,105 L160,95 L155,70 Z',
+ // Asia
+ 'M200,25 L260,20 L280,35 L290,50 L270,60 L240,55 L220,50 L200,40 Z',
+ // Australia
+ 'M260,85 L280,80 L295,90 L285,105 L265,105 L255,95 Z',
+ // Antarctica
+ 'M100,160 L260,160 L260,175 L100,175 Z',
+]
+
+// Major cities for reference
+const CITIES = [
+ { name: 'New York', lon: -74, lat: 40.7 },
+ { name: 'London', lon: -0.1, lat: 51.5 },
+ { name: 'Tokyo', lon: 139.7, lat: 35.7 },
+ { name: 'Sydney', lon: 151.2, lat: -33.9 },
+ { name: 'São Paulo', lon: -46.6, lat: -23.5 },
+]
+
+export function RegionMap({
+ bbox,
+ onBBoxChange,
+ highlightRegions = [],
+ className,
+ interactive = true,
+ showGrid = true,
+ showLabels = true,
+}: RegionMapProps) {
+ const svgRef = useRef(null)
+ const [isDragging, setIsDragging] = useState(false)
+ const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null)
+ const [currentBBox, setCurrentBBox] = useState(bbox || null)
+
+ // Update internal state when prop changes
+ useEffect(() => {
+ if (bbox) {
+ setCurrentBBox(bbox)
+ }
+ }, [bbox])
+
+ const getSvgCoords = useCallback((event: React.MouseEvent): { x: number; y: number } => {
+ if (!svgRef.current) return { x: 0, y: 0 }
+
+ const rect = svgRef.current.getBoundingClientRect()
+ const x = ((event.clientX - rect.left) / rect.width) * MAP_WIDTH
+ const y = ((event.clientY - rect.top) / rect.height) * MAP_HEIGHT
+
+ return { x, y }
+ }, [])
+
+ const handleMouseDown = useCallback((event: React.MouseEvent) => {
+ if (!interactive || !onBBoxChange) return
+
+ const coords = getSvgCoords(event)
+ setIsDragging(true)
+ setDragStart(coords)
+ setCurrentBBox(null)
+ }, [interactive, onBBoxChange, getSvgCoords])
+
+ const handleMouseMove = useCallback((event: React.MouseEvent) => {
+ if (!isDragging || !dragStart) return
+
+ const coords = getSvgCoords(event)
+ const minX = Math.min(dragStart.x, coords.x)
+ const maxX = Math.max(dragStart.x, coords.x)
+ const minY = Math.min(dragStart.y, coords.y)
+ const maxY = Math.max(dragStart.y, coords.y)
+
+ const minLon = xToLon(minX)
+ const maxLon = xToLon(maxX)
+ const maxLat = yToLat(minY)
+ const minLat = yToLat(maxY)
+
+ setCurrentBBox([minLon, minLat, maxLon, maxLat])
+ }, [isDragging, dragStart, getSvgCoords])
+
+ const handleMouseUp = useCallback(() => {
+ if (isDragging && currentBBox && onBBoxChange) {
+ onBBoxChange(currentBBox)
+ }
+ setIsDragging(false)
+ setDragStart(null)
+ }, [isDragging, currentBBox, onBBoxChange])
+
+ const renderBBox = (b: BBox, color: string, label?: string, key?: string) => {
+ const x = lonToX(b[0])
+ const y = latToY(b[3])
+ const width = lonToX(b[2]) - x
+ const height = latToY(b[1]) - y
+
+ return (
+
+
+ {label && (
+
+ {label}
+
+ )}
+
+ )
+ }
+
+ return (
+
+
+
+ {/* BBox display */}
+ {currentBBox && (
+
+ [{currentBBox.map((v) => v.toFixed(2)).join(', ')}]
+
+ )}
+
+ {interactive && (
+
+ Drag to select region
+
+ )}
+
+ )
+}
+
+// Mini map for displaying a single region
+export interface MiniMapProps {
+ bbox: BBox
+ className?: string
+ variant?: 'forest' | 'ice' | 'flood'
+}
+
+const variantColors = {
+ forest: '#22c55e',
+ ice: '#3b82f6',
+ flood: '#f59e0b',
+}
+
+export function MiniMap({ bbox, className, variant = 'forest' }: MiniMapProps) {
+ const color = variantColors[variant]
+
+ return (
+
+ )
+}
+
+// Preset regions selector
+export interface PresetRegion {
+ name: string
+ bbox: BBox
+ description?: string
+}
+
+const PRESET_REGIONS: PresetRegion[] = [
+ { name: 'Amazon Basin', bbox: [-73, -15, -45, 5], description: 'Primary deforestation monitoring' },
+ { name: 'Congo Basin', bbox: [8, -5, 30, 10], description: 'Central African rainforest' },
+ { name: 'Borneo', bbox: [108, -4, 120, 8], description: 'Southeast Asian rainforest' },
+ { name: 'Arctic Ocean', bbox: [-180, 66.5, 180, 90], description: 'Sea ice monitoring' },
+ { name: 'Greenland', bbox: [-73, 60, -12, 84], description: 'Ice sheet monitoring' },
+ { name: 'Bangladesh', bbox: [88, 20, 93, 27], description: 'Flood-prone region' },
+]
+
+interface PresetSelectorProps {
+ onSelect: (region: PresetRegion) => void
+ className?: string
+}
+
+export function PresetRegionSelector({ onSelect, className }: PresetSelectorProps) {
+ return (
+
+
Preset Regions
+
+ {PRESET_REGIONS.map((region) => (
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/components/Map/index.ts b/frontend/src/components/Map/index.ts
new file mode 100644
index 0000000..b844ec0
--- /dev/null
+++ b/frontend/src/components/Map/index.ts
@@ -0,0 +1,2 @@
+export { RegionMap, MiniMap, PresetRegionSelector } from './RegionMap'
+export type { BBox, RegionMapProps, MiniMapProps, PresetRegion } from './RegionMap'
diff --git a/frontend/src/components/ResultCard.tsx b/frontend/src/components/ResultCard.tsx
new file mode 100644
index 0000000..4cd5871
--- /dev/null
+++ b/frontend/src/components/ResultCard.tsx
@@ -0,0 +1,382 @@
+import { Card } from './ui/Card'
+import { Badge, StatusBadge, AnalysisTypeBadge } from './ui/Badge'
+import type { RunStatus, AnalysisType } from './ui/Badge'
+import { ConfidenceBar, CoverageBar } from './ui/ProgressBar'
+import { GaugeChart, getGaugeVariant, MiniGauge } from './charts/GaugeChart'
+import { StackedBar } from './charts/BarChart'
+import { InfoTooltip } from './ui/Tooltip'
+
+function cx(...parts: Array) {
+ return parts.filter(Boolean).join(' ')
+}
+
+// Types for inference results
+export interface RegionInfo {
+ bbox?: number[]
+ date_range?: string
+ images_available?: number
+}
+
+export interface NDVIStats {
+ NDVI_min: number
+ NDVI_mean: number
+ NDVI_max: number
+}
+
+export interface InferenceResult {
+ image_size?: [number, number]
+ forest_pixels?: number
+ non_forest_pixels?: number
+ forest_percentage?: number
+ mean_confidence?: number
+ // For ice melting
+ ice_pixels?: number
+ water_pixels?: number
+ ice_percentage?: number
+ // For flooding
+ flooded_pixels?: number
+ dry_pixels?: number
+ flooded_percentage?: number
+}
+
+export interface AnalysisResult {
+ region?: RegionInfo
+ ndvi_stats?: NDVIStats
+ inference?: InferenceResult
+ error?: string
+}
+
+export interface ResultCardProps {
+ result: AnalysisResult
+ runId?: number
+ status?: RunStatus
+ analysisType?: AnalysisType
+ createdAt?: string
+ showDetails?: boolean
+ onClick?: () => void
+ className?: string
+}
+
+// Format bbox for display
+function formatBBox(bbox?: number[]): string {
+ if (!bbox || bbox.length !== 4) return 'N/A'
+ return `[${bbox.map((v) => v.toFixed(2)).join(', ')}]`
+}
+
+// Format date for display
+function formatDate(dateStr?: string): string {
+ if (!dateStr) return 'N/A'
+ try {
+ const date = new Date(dateStr)
+ return date.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })
+ } catch {
+ return dateStr
+ }
+}
+
+// Get NDVI color based on value
+function getNDVIColor(value: number): string {
+ if (value >= 0.6) return 'text-brand-400'
+ if (value >= 0.3) return 'text-amber-400'
+ if (value >= 0) return 'text-orange-400'
+ return 'text-danger-400'
+}
+
+// NDVI indicator component
+function NDVIIndicator({ label, value }: { label: string; value: number }) {
+ return (
+
+
{label}
+
+ {value.toFixed(3)}
+
+
+ )
+}
+
+// Stat item component
+function StatItem({
+ label,
+ value,
+ subvalue,
+ tooltip,
+}: {
+ label: string
+ value: string | number
+ subvalue?: string
+ tooltip?: string
+}) {
+ return (
+
+
+ {label}
+ {tooltip && }
+
+
{value}
+ {subvalue &&
{subvalue}}
+
+ )
+}
+
+// Main ResultCard component
+export function ResultCard({
+ result,
+ runId,
+ status,
+ analysisType = 'deforestation',
+ createdAt,
+ showDetails = true,
+ onClick,
+ className,
+}: ResultCardProps) {
+ const { region, ndvi_stats, inference, error } = result
+
+ // Determine main metric based on analysis type
+ const getMainPercentage = (): number => {
+ if (!inference) return 0
+ switch (analysisType) {
+ case 'deforestation':
+ return inference.forest_percentage ?? 0
+ case 'ice_melting':
+ return inference.ice_percentage ?? 0
+ case 'flooding':
+ return inference.flooded_percentage ?? 0
+ default:
+ return inference.forest_percentage ?? 0
+ }
+ }
+
+ const mainPercentage = getMainPercentage()
+ const gaugeType = analysisType === 'ice_melting' ? 'ice' : analysisType === 'flooding' ? 'flood' : 'forest'
+ const gaugeVariant = getGaugeVariant(mainPercentage, gaugeType)
+
+ // Get pixel data for stacked bar
+ const getPixelData = () => {
+ if (!inference) return []
+
+ if (analysisType === 'deforestation') {
+ return [
+ { label: 'Forest', value: inference.forest_pixels ?? 0, color: 'bg-brand-500' },
+ { label: 'Non-Forest', value: inference.non_forest_pixels ?? 0, color: 'bg-amber-600' },
+ ]
+ }
+
+ if (analysisType === 'ice_melting') {
+ return [
+ { label: 'Ice', value: inference.ice_pixels ?? 0, color: 'bg-ocean-500' },
+ { label: 'Water', value: inference.water_pixels ?? 0, color: 'bg-blue-600' },
+ ]
+ }
+
+ if (analysisType === 'flooding') {
+ return [
+ { label: 'Flooded', value: inference.flooded_pixels ?? 0, color: 'bg-blue-500' },
+ { label: 'Dry', value: inference.dry_pixels ?? 0, color: 'bg-amber-600' },
+ ]
+ }
+
+ return []
+ }
+
+ const coverageLabel = analysisType === 'ice_melting' ? 'Ice Extent' : analysisType === 'flooding' ? 'Flooded Area' : 'Forest Coverage'
+
+ if (error) {
+ return (
+
+
+
+
+
+ {runId && Run #{runId}}
+
+
+
{error}
+
+
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ {runId && (
+
Run #{runId}
+ )}
+ {status &&
}
+
+
+ {createdAt && (
+
{formatDate(createdAt)}
+ )}
+
+
+ {/* Main content - Gauge and Stats */}
+
+ {/* Gauge */}
+
+
+
+
+ {/* Stats grid */}
+
+ {inference?.mean_confidence !== undefined && (
+
+
+
+ )}
+
+ {inference?.image_size && (
+
+ )}
+
+ {region?.images_available !== undefined && (
+
+ )}
+
+
+
+ {/* Pixel distribution */}
+ {showDetails && inference && (
+
+
+
+ )}
+
+ {/* NDVI Stats */}
+ {showDetails && ndvi_stats && (
+
+
+ NDVI Statistics
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Region Info */}
+ {showDetails && region && (
+
+
Region
+
+
+ BBox:
+ {formatBBox(region.bbox)}
+
+ {region.date_range && (
+
+ Date Range:
+ {region.date_range}
+
+ )}
+
+
+ )}
+
+ )
+}
+
+// Compact version for grid displays
+export function CompactResultCard({
+ result,
+ runId,
+ status,
+ analysisType = 'deforestation',
+ createdAt,
+ onClick,
+ selected,
+ className,
+}: ResultCardProps & { selected?: boolean }) {
+ const { inference, error } = result
+
+ const getMainPercentage = (): number => {
+ if (!inference) return 0
+ switch (analysisType) {
+ case 'deforestation':
+ return inference.forest_percentage ?? 0
+ case 'ice_melting':
+ return inference.ice_percentage ?? 0
+ case 'flooding':
+ return inference.flooded_percentage ?? 0
+ default:
+ return inference.forest_percentage ?? 0
+ }
+ }
+
+ const mainPercentage = getMainPercentage()
+ const gaugeType = analysisType === 'ice_melting' ? 'ice' : analysisType === 'flooding' ? 'flood' : 'forest'
+ const gaugeVariant = getGaugeVariant(mainPercentage, gaugeType)
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/charts/BarChart.tsx b/frontend/src/components/charts/BarChart.tsx
new file mode 100644
index 0000000..4f6ac96
--- /dev/null
+++ b/frontend/src/components/charts/BarChart.tsx
@@ -0,0 +1,252 @@
+function cx(...parts: Array) {
+ return parts.filter(Boolean).join(' ')
+}
+
+interface BarData {
+ label: string
+ value: number
+ color?: string
+}
+
+interface BarChartProps {
+ data: BarData[]
+ title?: string
+ maxValue?: number
+ showValues?: boolean
+ horizontal?: boolean
+ height?: number
+ className?: string
+}
+
+const defaultColors = [
+ 'bg-brand-500',
+ 'bg-ocean-500',
+ 'bg-amber-500',
+ 'bg-danger-500',
+ 'bg-purple-500',
+ 'bg-pink-500',
+]
+
+export function BarChart({
+ data,
+ title,
+ maxValue,
+ showValues = true,
+ horizontal = false,
+ height = 200,
+ className,
+}: BarChartProps) {
+ const max = maxValue || Math.max(...data.map((d) => d.value), 1)
+
+ if (horizontal) {
+ return (
+
+ {title && (
+
{title}
+ )}
+
+ {data.map((item, index) => {
+ const percentage = (item.value / max) * 100
+ const color = item.color || defaultColors[index % defaultColors.length]
+
+ return (
+
+
+ {item.label}
+ {showValues && (
+
+ {item.value.toLocaleString()}
+
+ )}
+
+
+
+ )
+ })}
+
+
+ )
+ }
+
+ // Vertical bar chart
+ return (
+
+ {title && (
+
{title}
+ )}
+
+ {data.map((item, index) => {
+ const percentage = (item.value / max) * 100
+ const color = item.color || defaultColors[index % defaultColors.length]
+
+ return (
+
+
+ {showValues && (
+
+ {item.value.toLocaleString()}
+
+ )}
+
+ {item.label}
+
+
+ )
+ })}
+
+
+ )
+}
+
+// Comparison bar for before/after or two values
+interface ComparisonBarProps {
+ label: string
+ value1: number
+ value2: number
+ label1?: string
+ label2?: string
+ showDiff?: boolean
+ className?: string
+}
+
+export function ComparisonBar({
+ label,
+ value1,
+ value2,
+ label1 = 'Before',
+ label2 = 'After',
+ showDiff = true,
+ className,
+}: ComparisonBarProps) {
+ const max = Math.max(value1, value2, 1)
+ const diff = value2 - value1
+ const diffPercent = value1 > 0 ? ((diff / value1) * 100).toFixed(1) : '0'
+
+ return (
+
+
+ {label}
+ {showDiff && (
+ 0 ? 'text-brand-400' : diff < 0 ? 'text-danger-400' : 'text-base-400',
+ )}
+ >
+ {diff > 0 ? '+' : ''}{diffPercent}%
+
+ )}
+
+
+
+
{label1}
+
+
+ {value1.toLocaleString()}
+
+
+
+
{label2}
+
+
= 0 ? 'bg-brand-500' : 'bg-danger-500',
+ )}
+ style={{ width: `${(value2 / max) * 100}%` }}
+ />
+
+
+ {value2.toLocaleString()}
+
+
+
+
+ )
+}
+
+// Stacked bar for composition display (e.g., forest vs non-forest)
+interface StackedBarData {
+ label: string
+ value: number
+ color: string
+}
+
+interface StackedBarProps {
+ data: StackedBarData[]
+ title?: string
+ showLegend?: boolean
+ className?: string
+}
+
+export function StackedBar({
+ data,
+ title,
+ showLegend = true,
+ className,
+}: StackedBarProps) {
+ const total = data.reduce((sum, item) => sum + item.value, 0)
+
+ return (
+
+ {title && (
+
{title}
+ )}
+
+ {data.map((item, index) => {
+ const percentage = total > 0 ? (item.value / total) * 100 : 0
+ return (
+
+ )
+ })}
+
+ {showLegend && (
+
+ {data.map((item) => {
+ const percentage = total > 0 ? (item.value / total) * 100 : 0
+ return (
+
+
+
+ {item.label}: {percentage.toFixed(1)}%
+
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/charts/GaugeChart.tsx b/frontend/src/components/charts/GaugeChart.tsx
new file mode 100644
index 0000000..945eaa3
--- /dev/null
+++ b/frontend/src/components/charts/GaugeChart.tsx
@@ -0,0 +1,198 @@
+function cx(...parts: Array
) {
+ return parts.filter(Boolean).join(' ')
+}
+
+export type GaugeVariant = 'forest' | 'ice' | 'water' | 'danger' | 'neutral'
+export type GaugeSize = 'sm' | 'md' | 'lg' | 'xl'
+
+interface GaugeChartProps {
+ value: number // 0-100
+ label?: string
+ sublabel?: string
+ variant?: GaugeVariant
+ size?: GaugeSize
+ showValue?: boolean
+ thickness?: number
+ className?: string
+}
+
+const variantColors: Record = {
+ forest: {
+ stroke: 'stroke-brand-500',
+ bg: 'stroke-brand-500/20',
+ text: 'text-brand-400',
+ },
+ ice: {
+ stroke: 'stroke-ocean-500',
+ bg: 'stroke-ocean-500/20',
+ text: 'text-ocean-400',
+ },
+ water: {
+ stroke: 'stroke-blue-500',
+ bg: 'stroke-blue-500/20',
+ text: 'text-blue-400',
+ },
+ danger: {
+ stroke: 'stroke-danger-500',
+ bg: 'stroke-danger-500/20',
+ text: 'text-danger-400',
+ },
+ neutral: {
+ stroke: 'stroke-base-400',
+ bg: 'stroke-base-700',
+ text: 'text-base-300',
+ },
+}
+
+const sizeConfig: Record = {
+ sm: { size: 80, strokeWidth: 6, fontSize: 'text-lg', subFontSize: 'text-xs' },
+ md: { size: 120, strokeWidth: 8, fontSize: 'text-2xl', subFontSize: 'text-sm' },
+ lg: { size: 160, strokeWidth: 10, fontSize: 'text-3xl', subFontSize: 'text-sm' },
+ xl: { size: 200, strokeWidth: 12, fontSize: 'text-4xl', subFontSize: 'text-base' },
+}
+
+export function GaugeChart({
+ value,
+ label,
+ sublabel,
+ variant = 'neutral',
+ size = 'md',
+ showValue = true,
+ thickness,
+ className,
+}: GaugeChartProps) {
+ const config = sizeConfig[size]
+ const colors = variantColors[variant]
+
+ const strokeWidth = thickness || config.strokeWidth
+ const radius = (config.size - strokeWidth) / 2
+ const circumference = 2 * Math.PI * radius
+ const percentage = Math.min(100, Math.max(0, value))
+ const offset = circumference - (percentage / 100) * circumference
+
+ return (
+
+
+
+
+ {/* Center content */}
+ {showValue && (
+
+
+ {percentage.toFixed(1)}%
+
+ {sublabel && (
+
+ {sublabel}
+
+ )}
+
+ )}
+
+
+ {label && (
+
{label}
+ )}
+
+ )
+}
+
+// Mini gauge for inline display
+interface MiniGaugeProps {
+ value: number
+ variant?: GaugeVariant
+ className?: string
+}
+
+export function MiniGauge({ value, variant = 'neutral', className }: MiniGaugeProps) {
+ const colors = variantColors[variant]
+ const percentage = Math.min(100, Math.max(0, value))
+ const radius = 14
+ const circumference = 2 * Math.PI * radius
+ const offset = circumference - (percentage / 100) * circumference
+
+ return (
+
+
+
+ {percentage.toFixed(1)}%
+
+
+ )
+}
+
+// Determine variant based on value thresholds for different analysis types
+export function getGaugeVariant(
+ value: number,
+ type: 'forest' | 'ice' | 'flood' = 'forest'
+): GaugeVariant {
+ if (type === 'forest') {
+ if (value >= 70) return 'forest'
+ if (value >= 40) return 'neutral'
+ return 'danger'
+ }
+
+ if (type === 'ice') {
+ if (value >= 70) return 'ice'
+ if (value >= 40) return 'neutral'
+ return 'danger'
+ }
+
+ if (type === 'flood') {
+ if (value >= 50) return 'danger'
+ if (value >= 20) return 'water'
+ return 'neutral'
+ }
+
+ return 'neutral'
+}
diff --git a/frontend/src/components/charts/TimeSeriesChart.tsx b/frontend/src/components/charts/TimeSeriesChart.tsx
new file mode 100644
index 0000000..d3a5b9e
--- /dev/null
+++ b/frontend/src/components/charts/TimeSeriesChart.tsx
@@ -0,0 +1,262 @@
+function cx(...parts: Array) {
+ return parts.filter(Boolean).join(' ')
+}
+
+interface DataPoint {
+ date: string
+ value: number
+ label?: string
+}
+
+interface TimeSeriesChartProps {
+ data: DataPoint[]
+ title?: string
+ yAxisLabel?: string
+ height?: number
+ showPoints?: boolean
+ showArea?: boolean
+ color?: string
+ className?: string
+}
+
+export function TimeSeriesChart({
+ data,
+ title,
+ yAxisLabel,
+ height = 200,
+ showPoints = true,
+ showArea = true,
+ color = 'brand',
+ className,
+}: TimeSeriesChartProps) {
+ if (data.length === 0) {
+ return (
+
+ {title && (
+
{title}
+ )}
+
+ No data available
+
+
+ )
+ }
+
+ const values = data.map((d) => d.value)
+ const minValue = Math.min(...values)
+ const maxValue = Math.max(...values)
+ const range = maxValue - minValue || 1
+
+ // Calculate SVG path
+ const chartWidth = 100 // percentage
+ const chartHeight = height - 40 // Leave room for labels
+ const padding = { top: 10, right: 10, bottom: 30, left: 40 }
+
+ const getX = (index: number) => {
+ const availableWidth = chartWidth - padding.left - padding.right
+ return padding.left + (index / (data.length - 1 || 1)) * availableWidth
+ }
+
+ const getY = (value: number) => {
+ const availableHeight = chartHeight - padding.top - padding.bottom
+ const normalized = (value - minValue) / range
+ return padding.top + availableHeight - normalized * availableHeight
+ }
+
+ // Generate path
+ const linePath = data
+ .map((point, index) => {
+ const x = getX(index)
+ const y = getY(point.value)
+ return `${index === 0 ? 'M' : 'L'} ${x} ${y}`
+ })
+ .join(' ')
+
+ // Generate area path
+ const areaPath = showArea
+ ? `${linePath} L ${getX(data.length - 1)} ${chartHeight - padding.bottom} L ${padding.left} ${chartHeight - padding.bottom} Z`
+ : ''
+
+ const colorClasses: Record = {
+ brand: {
+ stroke: 'stroke-brand-500',
+ fill: 'fill-brand-500/20',
+ dot: 'fill-brand-500',
+ },
+ ocean: {
+ stroke: 'stroke-ocean-500',
+ fill: 'fill-ocean-500/20',
+ dot: 'fill-ocean-500',
+ },
+ danger: {
+ stroke: 'stroke-danger-500',
+ fill: 'fill-danger-500/20',
+ dot: 'fill-danger-500',
+ },
+ amber: {
+ stroke: 'stroke-amber-500',
+ fill: 'fill-amber-500/20',
+ dot: 'fill-amber-500',
+ },
+ }
+
+ const colors = colorClasses[color] || colorClasses.brand
+
+ return (
+
+ {title && (
+
{title}
+ )}
+
+
+
+ {/* Y-axis labels */}
+
+ {maxValue.toFixed(1)}
+ {((maxValue + minValue) / 2).toFixed(1)}
+ {minValue.toFixed(1)}
+
+
+ {/* X-axis labels */}
+
+ {data.length > 0 && (
+ <>
+ {data[0].date}
+ {data.length > 2 && (
+ {data[Math.floor(data.length / 2)].date}
+ )}
+ {data[data.length - 1].date}
+ >
+ )}
+
+
+ {/* Y-axis label */}
+ {yAxisLabel && (
+
+ {yAxisLabel}
+
+ )}
+
+
+ )
+}
+
+// Sparkline for compact inline trends
+interface SparklineProps {
+ data: number[]
+ color?: string
+ width?: number
+ height?: number
+ className?: string
+}
+
+export function Sparkline({
+ data,
+ color = 'brand',
+ width = 60,
+ height = 20,
+ className,
+}: SparklineProps) {
+ if (data.length < 2) return null
+
+ const min = Math.min(...data)
+ const max = Math.max(...data)
+ const range = max - min || 1
+
+ const points = data
+ .map((value, index) => {
+ const x = (index / (data.length - 1)) * width
+ const y = height - ((value - min) / range) * height
+ return `${x},${y}`
+ })
+ .join(' ')
+
+ const trend = data[data.length - 1] - data[0]
+
+ const colorClasses: Record = {
+ brand: 'stroke-brand-500',
+ ocean: 'stroke-ocean-500',
+ danger: 'stroke-danger-500',
+ auto: trend >= 0 ? 'stroke-brand-500' : 'stroke-danger-500',
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/charts/index.ts b/frontend/src/components/charts/index.ts
new file mode 100644
index 0000000..4796df3
--- /dev/null
+++ b/frontend/src/components/charts/index.ts
@@ -0,0 +1,6 @@
+export { GaugeChart, MiniGauge, getGaugeVariant } from './GaugeChart'
+export type { GaugeVariant, GaugeSize } from './GaugeChart'
+
+export { BarChart, ComparisonBar, StackedBar } from './BarChart'
+
+export { TimeSeriesChart, Sparkline } from './TimeSeriesChart'
diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts
new file mode 100644
index 0000000..caa1232
--- /dev/null
+++ b/frontend/src/components/index.ts
@@ -0,0 +1,21 @@
+// UI Components
+export * from './ui'
+
+// Chart Components
+export * from './charts'
+
+// Result Components
+export { ResultCard, CompactResultCard } from './ResultCard'
+export type {
+ ResultCardProps,
+ RegionInfo,
+ NDVIStats,
+ InferenceResult,
+ AnalysisResult,
+} from './ResultCard'
+
+// NGO Components
+export * from './ngo'
+
+// Map Components
+export * from './Map'
diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx
new file mode 100644
index 0000000..805146a
--- /dev/null
+++ b/frontend/src/components/layout/Layout.tsx
@@ -0,0 +1,20 @@
+import { Outlet } from 'react-router-dom'
+import { Sidebar } from './Sidebar'
+import { TopBar } from './TopBar'
+import { useApp } from '../../contexts/AppContext'
+
+export function Layout() {
+ const { theme, toggleTheme } = useApp()
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx
new file mode 100644
index 0000000..a62aafa
--- /dev/null
+++ b/frontend/src/components/layout/Sidebar.tsx
@@ -0,0 +1,94 @@
+import { useState } from 'react'
+import { NavLink, useLocation } from 'react-router-dom'
+import { Map, Upload, Clock, BarChart2, Settings, ChevronLeft, ChevronRight, Leaf } from 'lucide-react'
+
+const NAV_ITEMS = [
+ { icon: Map, label: 'New Analysis', to: '/' },
+ { icon: Upload, label: 'Upload', to: '/upload' },
+ { icon: Clock, label: 'Run History', to: '/runs' },
+ { icon: BarChart2, label: 'Analytics', to: '/analytics' },
+ { icon: Settings, label: 'Settings', to: '/settings' },
+]
+
+export function Sidebar() {
+ const [expanded, setExpanded] = useState(true)
+ const location = useLocation()
+
+ return (
+ <>
+ {/* Desktop sidebar */}
+
+
+ {/* Mobile bottom tab bar */}
+
+
+ {/* Sidebar spacer for desktop */}
+
+ >
+ )
+}
diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx
new file mode 100644
index 0000000..81aeb2c
--- /dev/null
+++ b/frontend/src/components/layout/TopBar.tsx
@@ -0,0 +1,69 @@
+import { useEffect, useState } from 'react'
+import { useLocation } from 'react-router-dom'
+import { Sun, Moon, Wifi, WifiOff } from 'lucide-react'
+import { health } from '../../api'
+
+const PAGE_TITLES: Record = {
+ '/': 'New Analysis',
+ '/upload': 'Upload',
+ '/runs': 'Run History',
+ '/analytics': 'Analytics',
+ '/settings': 'Settings',
+}
+
+export function TopBar({ theme, onToggleTheme }: { theme: 'dark' | 'light'; onToggleTheme: () => void }) {
+ const location = useLocation()
+ const [apiOk, setApiOk] = useState(null)
+
+ const title = PAGE_TITLES[location.pathname] ?? 'ClimateVision'
+
+ useEffect(() => {
+ health()
+ .then(() => setApiOk(true))
+ .catch(() => setApiOk(false))
+ const interval = setInterval(() => {
+ health()
+ .then(() => setApiOk(true))
+ .catch(() => setApiOk(false))
+ }, 30_000)
+ return () => clearInterval(interval)
+ }, [])
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/ngo/AlertsPanel.tsx b/frontend/src/components/ngo/AlertsPanel.tsx
new file mode 100644
index 0000000..ec54fb1
--- /dev/null
+++ b/frontend/src/components/ngo/AlertsPanel.tsx
@@ -0,0 +1,323 @@
+import { useState } from 'react'
+import { Card } from '../ui/Card'
+import { Badge, SeverityBadge } from '../ui/Badge'
+import type { AlertSeverity } from '../ui/Badge'
+
+function cx(...parts: Array) {
+ return parts.filter(Boolean).join(' ')
+}
+
+export interface Alert {
+ id: number
+ organization_id: number
+ alert_type: string
+ severity: AlertSeverity
+ title: string
+ message: string
+ delivered: boolean
+ acknowledged: boolean
+ created_at: string
+ subscription_id?: number
+ run_id?: number
+}
+
+export interface AlertsPanelProps {
+ alerts: Alert[]
+ onAcknowledge?: (alertId: number) => void
+ onViewRun?: (runId: number) => void
+ onDismiss?: (alertId: number) => void
+ loading?: boolean
+ className?: string
+}
+
+type FilterType = 'all' | 'unacknowledged' | AlertSeverity
+
+export function AlertsPanel({
+ alerts,
+ onAcknowledge,
+ onViewRun,
+ onDismiss,
+ loading = false,
+ className,
+}: AlertsPanelProps) {
+ const [filter, setFilter] = useState('all')
+ const [expandedId, setExpandedId] = useState(null)
+
+ // Filter alerts
+ const filteredAlerts = alerts.filter((alert) => {
+ if (filter === 'all') return true
+ if (filter === 'unacknowledged') return !alert.acknowledged
+ return alert.severity === filter
+ })
+
+ // Count by severity
+ const counts = {
+ all: alerts.length,
+ unacknowledged: alerts.filter((a) => !a.acknowledged).length,
+ critical: alerts.filter((a) => a.severity === 'critical').length,
+ high: alerts.filter((a) => a.severity === 'high').length,
+ medium: alerts.filter((a) => a.severity === 'medium').length,
+ low: alerts.filter((a) => a.severity === 'low').length,
+ }
+
+ const formatDate = (dateStr: string) => {
+ const date = new Date(dateStr)
+ const now = new Date()
+ const diffMs = now.getTime() - date.getTime()
+ const diffMins = Math.floor(diffMs / 60000)
+ const diffHours = Math.floor(diffMs / 3600000)
+ const diffDays = Math.floor(diffMs / 86400000)
+
+ if (diffMins < 60) return `${diffMins}m ago`
+ if (diffHours < 24) return `${diffHours}h ago`
+ if (diffDays < 7) return `${diffDays}d ago`
+ return date.toLocaleDateString()
+ }
+
+ const getSeverityIcon = (severity: AlertSeverity) => {
+ switch (severity) {
+ case 'critical':
+ return (
+
+ )
+ case 'high':
+ return (
+
+ )
+ case 'medium':
+ return (
+
+ )
+ default:
+ return (
+
+ )
+ }
+ }
+
+ return (
+
+ {/* Filter tabs */}
+
+
+
+ {counts.critical > 0 && (
+
+ )}
+ {counts.high > 0 && (
+
+ )}
+
+
+ {/* Loading state */}
+ {loading && (
+
+ )}
+
+ {/* Empty state */}
+ {!loading && filteredAlerts.length === 0 && (
+
+
+
+ {filter === 'all' ? 'No alerts yet' : `No ${filter} alerts`}
+
+
+ )}
+
+ {/* Alerts list */}
+ {!loading && filteredAlerts.length > 0 && (
+
+ {filteredAlerts.map((alert) => (
+
+
+
+ {/* Expanded content */}
+ {expandedId === alert.id && (
+
+
{alert.message}
+
+
+
+ Type: {alert.alert_type}
+ {alert.run_id && Run: #{alert.run_id}}
+
+
+
+ {alert.run_id && onViewRun && (
+
+ )}
+ {!alert.acknowledged && onAcknowledge && (
+
+ )}
+ {onDismiss && (
+
+ )}
+
+
+
+ )}
+
+ ))}
+
+ )}
+
+ )
+}
+
+// Summary component for dashboard
+export interface AlertsSummaryProps {
+ alerts: Alert[]
+ className?: string
+}
+
+export function AlertsSummary({ alerts, className }: AlertsSummaryProps) {
+ const unacknowledged = alerts.filter((a) => !a.acknowledged)
+ const critical = unacknowledged.filter((a) => a.severity === 'critical' || a.severity === 'high')
+
+ if (unacknowledged.length === 0) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {critical.length > 0 && (
+
+
+
{critical.length} critical
+
+ )}
+
+ {unacknowledged.length} unacknowledged
+
+
+ )
+}
diff --git a/frontend/src/components/ngo/NGOResultCard.tsx b/frontend/src/components/ngo/NGOResultCard.tsx
new file mode 100644
index 0000000..8912acb
--- /dev/null
+++ b/frontend/src/components/ngo/NGOResultCard.tsx
@@ -0,0 +1,339 @@
+import { Card } from '../ui/Card'
+import { Badge, StatusBadge, SeverityBadge, AnalysisTypeBadge } from '../ui/Badge'
+import type { RunStatus, AlertSeverity, AnalysisType } from '../ui/Badge'
+import { GaugeChart, getGaugeVariant } from '../charts/GaugeChart'
+import { ComparisonBar } from '../charts/BarChart'
+import { InfoTooltip } from '../ui/Tooltip'
+
+function cx(...parts: Array) {
+ return parts.filter(Boolean).join(' ')
+}
+
+// Organization interface
+export interface Organization {
+ id: number
+ name: string
+ type: string
+ logo_url?: string
+ contact_email?: string
+}
+
+// Alert data for NGO context
+export interface NGOAlert {
+ id: number
+ alert_type: string
+ severity: AlertSeverity
+ title: string
+ message: string
+ created_at: string
+ acknowledged: boolean
+}
+
+// Region subscription
+export interface Subscription {
+ id: number
+ name?: string
+ bbox: number[]
+ analysis_types: AnalysisType[]
+ alert_threshold: number
+ active: boolean
+}
+
+// Analysis result with comparison data
+export interface NGOAnalysisResult {
+ current: {
+ forest_percentage?: number
+ ice_percentage?: number
+ flooded_percentage?: number
+ mean_confidence?: number
+ }
+ previous?: {
+ forest_percentage?: number
+ ice_percentage?: number
+ flooded_percentage?: number
+ }
+ change_detected: boolean
+ change_percentage?: number
+ region: {
+ bbox?: number[]
+ date_range?: string
+ }
+}
+
+export interface NGOResultCardProps {
+ organization: Organization
+ result: NGOAnalysisResult
+ subscription?: Subscription
+ alert?: NGOAlert
+ runId?: number
+ status?: RunStatus
+ analysisType?: AnalysisType
+ createdAt?: string
+ onAcknowledge?: () => void
+ onInvestigate?: () => void
+ onExport?: () => void
+ className?: string
+}
+
+// Format bbox for display
+function formatBBox(bbox?: number[]): string {
+ if (!bbox || bbox.length !== 4) return 'N/A'
+ return `${bbox[0].toFixed(2)}°, ${bbox[1].toFixed(2)}° to ${bbox[2].toFixed(2)}°, ${bbox[3].toFixed(2)}°`
+}
+
+export function NGOResultCard({
+ organization,
+ result,
+ subscription,
+ alert,
+ runId,
+ status = 'completed',
+ analysisType = 'deforestation',
+ createdAt,
+ onAcknowledge,
+ onInvestigate,
+ onExport,
+ className,
+}: NGOResultCardProps) {
+ // Get the main percentage based on analysis type
+ const getCurrentPercentage = (): number => {
+ const { current } = result
+ switch (analysisType) {
+ case 'deforestation':
+ return current.forest_percentage ?? 0
+ case 'ice_melting':
+ return current.ice_percentage ?? 0
+ case 'flooding':
+ return current.flooded_percentage ?? 0
+ default:
+ return current.forest_percentage ?? 0
+ }
+ }
+
+ const getPreviousPercentage = (): number | undefined => {
+ const { previous } = result
+ if (!previous) return undefined
+ switch (analysisType) {
+ case 'deforestation':
+ return previous.forest_percentage
+ case 'ice_melting':
+ return previous.ice_percentage
+ case 'flooding':
+ return previous.flooded_percentage
+ default:
+ return previous.forest_percentage
+ }
+ }
+
+ const currentPercentage = getCurrentPercentage()
+ const previousPercentage = getPreviousPercentage()
+ const gaugeType = analysisType === 'ice_melting' ? 'ice' : analysisType === 'flooding' ? 'flood' : 'forest'
+ const gaugeVariant = getGaugeVariant(currentPercentage, gaugeType)
+
+ const coverageLabel =
+ analysisType === 'ice_melting' ? 'Ice Extent' :
+ analysisType === 'flooding' ? 'Flooded Area' :
+ 'Forest Coverage'
+
+ const cardVariant = alert ? (
+ alert.severity === 'critical' || alert.severity === 'high' ? 'danger' :
+ alert.severity === 'medium' ? 'warning' : 'default'
+ ) : 'default'
+
+ return (
+
+ {/* Organization Header */}
+
+
+ {organization.logo_url ? (
+

+ ) : (
+
+
+ {organization.name.slice(0, 2).toUpperCase()}
+
+
+ )}
+
+
{organization.name}
+
+ {organization.type.toUpperCase()}
+ {subscription?.name && (
+ {subscription.name}
+ )}
+
+
+
+
+
+ {runId && Run #{runId}}
+
+
+
+
+ {/* Alert Banner */}
+ {alert && (
+
+
+
+
+
+
+ {alert.title}
+
+
+
{alert.message}
+
+
+ {!alert.acknowledged && onAcknowledge && (
+
+ )}
+
+
+ )}
+
+ {/* Main Content */}
+
+ {/* Gauge */}
+
+
+
+
+ {/* Stats and Change Detection */}
+
+ {/* Analysis Type */}
+
+
+ {result.change_detected && (
+
+ Change Detected
+
+ )}
+
+
+ {/* Change Comparison */}
+ {previousPercentage !== undefined && (
+
+ )}
+
+ {/* Confidence */}
+ {result.current.mean_confidence !== undefined && (
+
+
+ Model Confidence
+
+
+
+
+
= 0.8 ? 'bg-brand-500' :
+ result.current.mean_confidence >= 0.6 ? 'bg-ocean-500' :
+ result.current.mean_confidence >= 0.4 ? 'bg-amber-500' : 'bg-danger-500'
+ )}
+ style={{ width: `${result.current.mean_confidence * 100}%` }}
+ />
+
+
+ {(result.current.mean_confidence * 100).toFixed(1)}%
+
+
+
+ )}
+
+
+
+ {/* Region Info */}
+
+
+
+ Region:
+ {formatBBox(result.region.bbox)}
+
+ {result.region.date_range && (
+
+ Period:
+ {result.region.date_range}
+
+ )}
+ {subscription?.alert_threshold && (
+
+ Alert Threshold:
+ {subscription.alert_threshold}% change
+
+ )}
+ {createdAt && (
+
+ Analyzed:
+
+ {new Date(createdAt).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+
+ )}
+
+
+
+ {/* Action Buttons */}
+ {(onInvestigate || onExport) && (
+
+ {onInvestigate && (
+
+ )}
+ {onExport && (
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ngo/SubscriptionManager.tsx b/frontend/src/components/ngo/SubscriptionManager.tsx
new file mode 100644
index 0000000..bda8883
--- /dev/null
+++ b/frontend/src/components/ngo/SubscriptionManager.tsx
@@ -0,0 +1,468 @@
+import { useState } from 'react'
+import { Card } from '../ui/Card'
+import { Badge, AnalysisTypeBadge } from '../ui/Badge'
+import type { AnalysisType } from '../ui/Badge'
+
+function cx(...parts: Array
) {
+ return parts.filter(Boolean).join(' ')
+}
+
+export interface Subscription {
+ id: number
+ organization_id: number
+ name?: string
+ description?: string
+ bbox: number[]
+ analysis_types: AnalysisType[]
+ alert_threshold: number
+ notification_channel: string
+ webhook_url?: string
+ active: boolean
+ last_checked_at?: string
+ created_at: string
+}
+
+export interface SubscriptionManagerProps {
+ subscriptions: Subscription[]
+ onAdd?: () => void
+ onEdit?: (subscription: Subscription) => void
+ onDelete?: (subscriptionId: number) => void
+ onToggle?: (subscriptionId: number, active: boolean) => void
+ loading?: boolean
+ className?: string
+}
+
+// Format bbox for display
+function formatBBox(bbox: number[]): string {
+ if (!bbox || bbox.length !== 4) return 'Invalid region'
+ return `${bbox[0].toFixed(3)}°, ${bbox[1].toFixed(3)}° to ${bbox[2].toFixed(3)}°, ${bbox[3].toFixed(3)}°`
+}
+
+// Calculate approximate area from bbox
+function calculateArea(bbox: number[]): string {
+ if (!bbox || bbox.length !== 4) return 'N/A'
+ const [minLon, minLat, maxLon, maxLat] = bbox
+ const latDiff = Math.abs(maxLat - minLat)
+ const lonDiff = Math.abs(maxLon - minLon)
+ // Rough approximation: 1 degree ≈ 111 km at equator
+ const avgLat = (minLat + maxLat) / 2
+ const lonKm = lonDiff * 111 * Math.cos((avgLat * Math.PI) / 180)
+ const latKm = latDiff * 111
+ const areaKm2 = lonKm * latKm
+
+ if (areaKm2 < 1) return `${(areaKm2 * 1000000).toFixed(0)} m²`
+ if (areaKm2 < 100) return `${areaKm2.toFixed(2)} km²`
+ if (areaKm2 < 10000) return `${areaKm2.toFixed(0)} km²`
+ return `${(areaKm2 / 1000).toFixed(1)}k km²`
+}
+
+export function SubscriptionManager({
+ subscriptions,
+ onAdd,
+ onEdit,
+ onDelete,
+ onToggle,
+ loading = false,
+ className,
+}: SubscriptionManagerProps) {
+ const [expandedId, setExpandedId] = useState(null)
+
+ const activeCount = subscriptions.filter((s) => s.active).length
+
+ return (
+
+ + Add Region
+
+ )
+ }
+ className={className}
+ >
+ {/* Stats */}
+
+
+ Total:
+ {subscriptions.length}
+
+
+ Active:
+ {activeCount}
+
+
+ Paused:
+ {subscriptions.length - activeCount}
+
+
+
+ {/* Loading state */}
+ {loading && (
+
+
+
Loading subscriptions...
+
+ )}
+
+ {/* Empty state */}
+ {!loading && subscriptions.length === 0 && (
+
+
+
No monitored regions yet
+ {onAdd && (
+
+ )}
+
+ )}
+
+ {/* Subscriptions list */}
+ {!loading && subscriptions.length > 0 && (
+
+ {subscriptions.map((sub) => (
+
+ {/* Header */}
+
+
+ {/* Expanded details */}
+ {expandedId === sub.id && (
+
+
+
+ Alert Threshold:
+ {sub.alert_threshold}% change
+
+
+ Notification:
+ {sub.notification_channel}
+
+ {sub.webhook_url && (
+
+ Webhook:
+
+ {sub.webhook_url}
+
+
+ )}
+ {sub.last_checked_at && (
+
+ Last Checked:
+
+ {new Date(sub.last_checked_at).toLocaleDateString()}
+
+
+ )}
+
+ Created:
+
+ {new Date(sub.created_at).toLocaleDateString()}
+
+
+
+
+ {sub.description && (
+
{sub.description}
+ )}
+
+ {/* Actions */}
+
+
+ {onToggle && (
+
+ )}
+
+
+
+ {onEdit && (
+
+ )}
+ {onDelete && (
+
+ )}
+
+
+
+ )}
+
+ ))}
+
+ )}
+
+ )
+}
+
+// Form for creating/editing subscriptions
+export interface SubscriptionFormData {
+ name: string
+ description: string
+ bbox: string
+ analysis_types: AnalysisType[]
+ alert_threshold: number
+ notification_channel: string
+ webhook_url: string
+}
+
+export interface SubscriptionFormProps {
+ initialData?: Partial
+ onSubmit: (data: SubscriptionFormData) => void
+ onCancel: () => void
+ loading?: boolean
+}
+
+export function SubscriptionForm({
+ initialData,
+ onSubmit,
+ onCancel,
+ loading = false,
+}: SubscriptionFormProps) {
+ const [formData, setFormData] = useState({
+ name: initialData?.name || '',
+ description: initialData?.description || '',
+ bbox: initialData?.bbox || '[-62.0, -3.1, -61.8, -2.9]',
+ analysis_types: initialData?.analysis_types || ['deforestation'],
+ alert_threshold: initialData?.alert_threshold || 5,
+ notification_channel: initialData?.notification_channel || 'email',
+ webhook_url: initialData?.webhook_url || '',
+ })
+
+ const analysisTypeOptions: { value: AnalysisType; label: string }[] = [
+ { value: 'deforestation', label: 'Deforestation Detection' },
+ { value: 'ice_melting', label: 'Arctic Ice Melting' },
+ { value: 'flooding', label: 'Flood Detection' },
+ ]
+
+ const handleAnalysisTypeToggle = (type: AnalysisType) => {
+ setFormData((prev) => ({
+ ...prev,
+ analysis_types: prev.analysis_types.includes(type)
+ ? prev.analysis_types.filter((t) => t !== type)
+ : [...prev.analysis_types, type],
+ }))
+ }
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault()
+ onSubmit(formData)
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/ngo/index.ts b/frontend/src/components/ngo/index.ts
new file mode 100644
index 0000000..4e860b0
--- /dev/null
+++ b/frontend/src/components/ngo/index.ts
@@ -0,0 +1,19 @@
+export { NGOResultCard } from './NGOResultCard'
+export type {
+ Organization,
+ NGOAlert,
+ Subscription as NGOSubscription,
+ NGOAnalysisResult,
+ NGOResultCardProps,
+} from './NGOResultCard'
+
+export { AlertsPanel, AlertsSummary } from './AlertsPanel'
+export type { Alert, AlertsPanelProps, AlertsSummaryProps } from './AlertsPanel'
+
+export { SubscriptionManager, SubscriptionForm } from './SubscriptionManager'
+export type {
+ Subscription,
+ SubscriptionManagerProps,
+ SubscriptionFormData,
+ SubscriptionFormProps,
+} from './SubscriptionManager'
diff --git a/frontend/src/components/results/ConfidenceGauge.tsx b/frontend/src/components/results/ConfidenceGauge.tsx
new file mode 100644
index 0000000..f9b55a6
--- /dev/null
+++ b/frontend/src/components/results/ConfidenceGauge.tsx
@@ -0,0 +1,71 @@
+import { useEffect, useState } from 'react'
+
+interface ConfidenceGaugeProps {
+ value: number // 0-100
+ size?: number
+}
+
+export function ConfidenceGauge({ value, size = 120 }: ConfidenceGaugeProps) {
+ const [animated, setAnimated] = useState(0)
+ const r = (size / 2) * 0.75
+ const cx = size / 2
+ const circumference = 2 * Math.PI * r
+ const arc = (animated / 100) * circumference * 0.75 // 270 degree arc
+
+ const color = animated >= 70 ? '#22c55e' : animated >= 40 ? '#f59e0b' : '#ef4444'
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ let start = 0
+ const step = () => {
+ start += 2
+ setAnimated(Math.min(start, value))
+ if (start < value) requestAnimationFrame(step)
+ }
+ requestAnimationFrame(step)
+ }, 200)
+ return () => clearTimeout(timer)
+ }, [value])
+
+ const dashArray = `${arc} ${circumference}`
+ const rotation = -135 // start from bottom-left
+
+ return (
+
+
+ Detection Confidence
+
+ )
+}
diff --git a/frontend/src/components/results/ResultsPanel.tsx b/frontend/src/components/results/ResultsPanel.tsx
new file mode 100644
index 0000000..289a3e4
--- /dev/null
+++ b/frontend/src/components/results/ResultsPanel.tsx
@@ -0,0 +1,218 @@
+import { useEffect, useRef, useState } from 'react'
+import { Download, Share2, RotateCcw, Map as MapIcon } from 'lucide-react'
+import type { Run } from '../../api'
+import { StatusBadge } from '../ui/StatusBadge'
+import { ConfidenceGauge } from './ConfidenceGauge'
+import { useApp } from '../../contexts/AppContext'
+
+interface ResultPayload {
+ inference?: {
+ forest_percentage?: number
+ ice_percentage?: number
+ flooded_percentage?: number
+ mean_confidence?: number
+ image_size?: [number, number]
+ forest_pixels?: number
+ non_forest_pixels?: number
+ ice_pixels?: number
+ water_pixels?: number
+ flooded_pixels?: number
+ dry_pixels?: number
+ }
+ ndvi_stats?: { NDVI_min: number; NDVI_mean: number; NDVI_max: number }
+ region?: { bbox?: number[]; date_range?: string }
+ analysis_type?: string
+ error?: string
+}
+
+interface ResultsPanelProps {
+ run: Run
+ payload: ResultPayload | null
+ onRunAgain?: () => void
+}
+
+function StatCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
+ return (
+
+ {label}
+ {value}
+ {sub && {sub}}
+
+ )
+}
+
+function StaticMapImage({ bbox, apiKey }: { bbox: number[]; apiKey: string }) {
+ if (!apiKey || apiKey === 'YOUR_GOOGLE_MAPS_API_KEY_HERE') {
+ return (
+
+
+
+ )
+ }
+ const lat = (bbox[1] + bbox[3]) / 2
+ const lon = (bbox[0] + bbox[2]) / 2
+ const path = `color:0x22c55ecc|weight:2|${bbox[1]},${bbox[0]}|${bbox[3]},${bbox[0]}|${bbox[3]},${bbox[2]}|${bbox[1]},${bbox[2]}|${bbox[1]},${bbox[0]}`
+ const src = `https://maps.googleapis.com/maps/api/staticmap?center=${lat},${lon}&zoom=8&size=600x300&maptype=satellite&path=${encodeURIComponent(path)}&key=${apiKey}`
+ return (
+
+ )
+}
+
+export function ResultsPanel({ run, payload, onRunAgain }: ResultsPanelProps) {
+ const { googleMapsApiKey } = useApp()
+ const bbox = run.bbox ? JSON.parse(run.bbox) : payload?.region?.bbox ?? null
+ const inf = payload?.inference
+ const confidence = (inf?.mean_confidence ?? 0) * 100
+
+ const analysisType = run.analysis_type ?? payload?.analysis_type ?? 'deforestation'
+
+ const mainPct =
+ analysisType === 'ice_melting'
+ ? inf?.ice_percentage ?? 0
+ : analysisType === 'flooding'
+ ? inf?.flooded_percentage ?? 0
+ : inf?.forest_percentage ?? 0
+
+ const mainLabel =
+ analysisType === 'ice_melting' ? 'Ice Extent' : analysisType === 'flooding' ? 'Flooded Area' : 'Forest Coverage'
+
+ const totalPixels = (inf?.forest_pixels ?? 0) + (inf?.non_forest_pixels ?? inf?.water_pixels ?? inf?.dry_pixels ?? 0) + (inf?.ice_pixels ?? 0)
+
+ const copyRunLink = () => {
+ navigator.clipboard.writeText(`${window.location.origin}/runs#run-${run.id}`)
+ }
+
+ const downloadGeoJSON = () => {
+ if (!bbox) return
+ const geojson = {
+ type: 'Feature',
+ properties: { run_id: run.id, analysis_type: analysisType, ...payload },
+ geometry: {
+ type: 'Polygon',
+ coordinates: [[[bbox[0], bbox[1]], [bbox[2], bbox[1]], [bbox[2], bbox[3]], [bbox[0], bbox[3]], [bbox[0], bbox[1]]]],
+ },
+ }
+ const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/json' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `run-${run.id}.geojson`
+ a.click()
+ URL.revokeObjectURL(url)
+ }
+
+ if (payload?.error && !inf) {
+ return (
+
+
+ Run #{run.id}
+
+
+
+ {onRunAgain && (
+
+ )}
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+ Run #{run.id}
+
+
+ {new Date(run.created_at).toLocaleDateString()}
+
+
+
+ {/* Satellite image */}
+ {bbox && (
+
+
Region
+
+
+ )}
+
+ {/* Confidence + main metric */}
+
+
+
+
+
{mainLabel}
+
{mainPct.toFixed(1)}%
+
+
+
+
+
+ {/* Key metrics */}
+ {inf && (
+
+
+
+ {inf.image_size && (
+
+ )}
+
+
+ )}
+
+ {/* NDVI */}
+ {payload?.ndvi_stats && (
+
+
NDVI Statistics
+
+ {[
+ { label: 'Min', value: payload.ndvi_stats.NDVI_min },
+ { label: 'Mean', value: payload.ndvi_stats.NDVI_mean },
+ { label: 'Max', value: payload.ndvi_stats.NDVI_max },
+ ].map(({ label, value }) => (
+
+
{label}
+
= 0.3 ? 'text-cv-primary' : value >= 0 ? 'text-amber-400' : 'text-red-400'}`}>
+ {value.toFixed(3)}
+
+
+ ))}
+
+
+ )}
+
+ {/* Actions */}
+
+ {bbox && (
+
+ )}
+
+ {onRunAgain && (
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/runs/RunCard.tsx b/frontend/src/components/runs/RunCard.tsx
new file mode 100644
index 0000000..233082c
--- /dev/null
+++ b/frontend/src/components/runs/RunCard.tsx
@@ -0,0 +1,123 @@
+import { Map } from 'lucide-react'
+import type { Run } from '../../api'
+import { StatusBadge } from '../ui/StatusBadge'
+import { useGeocoding } from '../../hooks/useGeocoding'
+import { useApp } from '../../contexts/AppContext'
+
+const ANALYSIS_EMOJI: Record = {
+ deforestation: '🌲',
+ ice_melting: '🧊',
+ flooding: '🌊',
+ drought: '🏜️',
+ wildfire: '🔥',
+}
+
+const ANALYSIS_LABEL: Record = {
+ deforestation: 'Deforestation Detection',
+ ice_melting: 'Arctic Ice Melting',
+ flooding: 'Flood Detection',
+ drought: 'Drought Monitoring',
+ wildfire: 'Wildfire Detection',
+}
+
+interface RunCardProps {
+ run: Run
+ selected?: boolean
+ onClick?: () => void
+ confidence?: number
+}
+
+function StaticMapThumb({ bbox, apiKey }: { bbox: number[]; apiKey: string }) {
+ if (!apiKey || apiKey === 'YOUR_GOOGLE_MAPS_API_KEY_HERE') {
+ return (
+
+
+
+ )
+ }
+ const lat = (bbox[1] + bbox[3]) / 2
+ const lon = (bbox[0] + bbox[2]) / 2
+ const path = `color:0x22c55ecc|weight:2|${bbox[1]},${bbox[0]}|${bbox[3]},${bbox[0]}|${bbox[3]},${bbox[2]}|${bbox[1]},${bbox[2]}|${bbox[1]},${bbox[0]}`
+ const src = `https://maps.googleapis.com/maps/api/staticmap?center=${lat},${lon}&zoom=7&size=400x150&maptype=satellite&path=${encodeURIComponent(path)}&key=${apiKey}`
+ return
+}
+
+export function RunCard({ run, selected, onClick, confidence }: RunCardProps) {
+ const { googleMapsApiKey } = useApp()
+ const bbox: number[] | null = run.bbox ? (() => { try { return JSON.parse(run.bbox!) } catch { return null } })() : null
+ const regionName = useGeocoding(bbox, googleMapsApiKey)
+
+ const date = new Date(run.created_at).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ })
+
+ const isRunning = run.status === 'running'
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/ui/AnalysisTypeSelector.tsx b/frontend/src/components/ui/AnalysisTypeSelector.tsx
new file mode 100644
index 0000000..ed91c76
--- /dev/null
+++ b/frontend/src/components/ui/AnalysisTypeSelector.tsx
@@ -0,0 +1,63 @@
+import type { AnalysisType } from '../../api'
+import { CheckCircle } from 'lucide-react'
+
+interface AnalysisOption {
+ value: AnalysisType
+ emoji: string
+ label: string
+ description: string
+ enabled: boolean
+}
+
+const OPTIONS: AnalysisOption[] = [
+ { value: 'deforestation', emoji: '🌲', label: 'Deforestation Detection', description: 'Track forest cover loss', enabled: true },
+ { value: 'ice_melting', emoji: '🧊', label: 'Arctic Ice Melting', description: 'Monitor polar ice extent', enabled: true },
+ { value: 'flooding', emoji: '🌊', label: 'Flood Detection', description: 'Identify inundated areas', enabled: true },
+ { value: 'drought', emoji: '🏜️', label: 'Drought Monitoring', description: 'Measure vegetation stress', enabled: false },
+ { value: 'wildfire', emoji: '🔥', label: 'Wildfire Detection', description: 'Detect active burn zones', enabled: false },
+]
+
+export function AnalysisTypeSelector({
+ value,
+ onChange,
+}: {
+ value: AnalysisType
+ onChange: (v: AnalysisType) => void
+}) {
+ return (
+
+ {OPTIONS.map((opt) => {
+ const selected = value === opt.value
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx
new file mode 100644
index 0000000..b522517
--- /dev/null
+++ b/frontend/src/components/ui/Badge.tsx
@@ -0,0 +1,137 @@
+import { ReactNode } from 'react'
+
+function cx(...parts: Array) {
+ return parts.filter(Boolean).join(' ')
+}
+
+export type BadgeVariant = 'default' | 'success' | 'warning' | 'danger' | 'info' | 'neutral'
+export type BadgeSize = 'sm' | 'md' | 'lg'
+
+interface BadgeProps {
+ children: ReactNode
+ variant?: BadgeVariant
+ size?: BadgeSize
+ dot?: boolean
+ className?: string
+}
+
+const variantStyles: Record = {
+ default: 'bg-base-800 text-base-200',
+ success: 'bg-brand-600/20 text-brand-400 border-brand-600/30',
+ warning: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
+ danger: 'bg-danger-500/20 text-danger-400 border-danger-500/30',
+ info: 'bg-ocean-500/20 text-ocean-400 border-ocean-500/30',
+ neutral: 'bg-base-700/50 text-base-300 border-base-600/30',
+}
+
+const sizeStyles: Record = {
+ sm: 'text-xs px-1.5 py-0.5',
+ md: 'text-xs px-2 py-1',
+ lg: 'text-sm px-2.5 py-1',
+}
+
+const dotColors: Record = {
+ default: 'bg-base-400',
+ success: 'bg-brand-400',
+ warning: 'bg-amber-400',
+ danger: 'bg-danger-400',
+ info: 'bg-ocean-400',
+ neutral: 'bg-base-400',
+}
+
+export function Badge({
+ children,
+ variant = 'default',
+ size = 'md',
+ dot = false,
+ className,
+}: BadgeProps) {
+ return (
+
+ {dot && (
+
+ )}
+ {children}
+
+ )
+}
+
+// Status badge specifically for run status
+export type RunStatus = 'running' | 'completed' | 'failed' | 'pending'
+
+interface StatusBadgeProps {
+ status: RunStatus
+ size?: BadgeSize
+}
+
+const statusConfig: Record = {
+ running: { variant: 'info', label: 'Running' },
+ completed: { variant: 'success', label: 'Completed' },
+ failed: { variant: 'danger', label: 'Failed' },
+ pending: { variant: 'neutral', label: 'Pending' },
+}
+
+export function StatusBadge({ status, size = 'sm' }: StatusBadgeProps) {
+ const config = statusConfig[status] || statusConfig.pending
+ return (
+
+ {config.label}
+
+ )
+}
+
+// Severity badge for alerts
+export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical'
+
+interface SeverityBadgeProps {
+ severity: AlertSeverity
+ size?: BadgeSize
+}
+
+const severityConfig: Record = {
+ low: { variant: 'neutral', label: 'Low' },
+ medium: { variant: 'warning', label: 'Medium' },
+ high: { variant: 'danger', label: 'High' },
+ critical: { variant: 'danger', label: 'Critical' },
+}
+
+export function SeverityBadge({ severity, size = 'sm' }: SeverityBadgeProps) {
+ const config = severityConfig[severity] || severityConfig.low
+ return (
+
+ {config.label}
+
+ )
+}
+
+// Analysis type badge
+export type AnalysisType = 'deforestation' | 'ice_melting' | 'flooding' | 'drought' | 'wildfire'
+
+interface AnalysisTypeBadgeProps {
+ type: AnalysisType
+ size?: BadgeSize
+}
+
+const analysisTypeConfig: Record = {
+ deforestation: { variant: 'success', label: 'Deforestation' },
+ ice_melting: { variant: 'info', label: 'Ice Melting' },
+ flooding: { variant: 'info', label: 'Flooding' },
+ drought: { variant: 'warning', label: 'Drought' },
+ wildfire: { variant: 'danger', label: 'Wildfire' },
+}
+
+export function AnalysisTypeBadge({ type, size = 'sm' }: AnalysisTypeBadgeProps) {
+ const config = analysisTypeConfig[type] || { variant: 'default' as BadgeVariant, label: type }
+ return (
+
+ {config.label}
+
+ )
+}
diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx
new file mode 100644
index 0000000..0e43b89
--- /dev/null
+++ b/frontend/src/components/ui/Card.tsx
@@ -0,0 +1,97 @@
+import { ReactNode } from 'react'
+
+function cx(...parts: Array) {
+ return parts.filter(Boolean).join(' ')
+}
+
+export type CardVariant = 'default' | 'elevated' | 'outlined' | 'success' | 'warning' | 'danger'
+
+interface CardProps {
+ title?: string
+ subtitle?: string
+ children: ReactNode
+ right?: ReactNode
+ footer?: ReactNode
+ variant?: CardVariant
+ className?: string
+ onClick?: () => void
+ hoverable?: boolean
+}
+
+const variantStyles: Record = {
+ default: 'border-base-800 bg-base-900/70',
+ elevated: 'border-base-700 bg-base-900/90 shadow-lg',
+ outlined: 'border-base-700 bg-transparent',
+ success: 'border-brand-600/40 bg-brand-900/20',
+ warning: 'border-amber-500/40 bg-amber-900/20',
+ danger: 'border-danger-500/40 bg-danger-900/20',
+}
+
+export function Card({
+ title,
+ subtitle,
+ children,
+ right,
+ footer,
+ variant = 'default',
+ className,
+ onClick,
+ hoverable = false,
+}: CardProps) {
+ const Component = onClick ? 'button' : 'div'
+
+ return (
+
+ {(title || right) && (
+
+
+ {title && (
+
{title}
+ )}
+ {subtitle && (
+
{subtitle}
+ )}
+
+ {right}
+
+ )}
+ {children}
+ {footer && (
+ {footer}
+ )}
+
+ )
+}
+
+// Compact card for grid displays
+interface CompactCardProps {
+ children: ReactNode
+ className?: string
+ onClick?: () => void
+ selected?: boolean
+}
+
+export function CompactCard({ children, className, onClick, selected }: CompactCardProps) {
+ return (
+
+ )
+}
diff --git a/frontend/src/components/ui/EmptyState.tsx b/frontend/src/components/ui/EmptyState.tsx
new file mode 100644
index 0000000..9c776fe
--- /dev/null
+++ b/frontend/src/components/ui/EmptyState.tsx
@@ -0,0 +1,33 @@
+import { ReactNode } from 'react'
+
+interface EmptyStateProps {
+ icon?: ReactNode
+ heading: string
+ subtext?: string
+ action?: ReactNode
+}
+
+export function EmptyState({ icon, heading, subtext, action }: EmptyStateProps) {
+ return (
+
+ {icon &&
{icon}
}
+
{heading}
+ {subtext &&
{subtext}
}
+ {action &&
{action}
}
+
+ )
+}
+
+// Satellite SVG illustration
+export function SatelliteIllustration() {
+ return (
+
+ )
+}
diff --git a/frontend/src/components/ui/ErrorBoundary.tsx b/frontend/src/components/ui/ErrorBoundary.tsx
new file mode 100644
index 0000000..9479182
--- /dev/null
+++ b/frontend/src/components/ui/ErrorBoundary.tsx
@@ -0,0 +1,36 @@
+import { Component, ReactNode } from 'react'
+import { AlertTriangle } from 'lucide-react'
+
+interface Props { children: ReactNode; section?: string }
+interface State { hasError: boolean; error?: Error }
+
+export class ErrorBoundary extends Component {
+ state: State = { hasError: false }
+
+ static getDerivedStateFromError(error: Error): State {
+ return { hasError: true, error }
+ }
+
+ render() {
+ if (!this.state.hasError) return this.props.children
+ return (
+
+
+
+ Something went wrong{this.props.section ? ` in ${this.props.section}` : ''}
+
+
+ {this.state.error?.message ?? 'An unexpected error occurred'}
+
+
+
+ )
+ }
+}
diff --git a/frontend/src/components/ui/ProgressBar.tsx b/frontend/src/components/ui/ProgressBar.tsx
new file mode 100644
index 0000000..46d821a
--- /dev/null
+++ b/frontend/src/components/ui/ProgressBar.tsx
@@ -0,0 +1,145 @@
+function cx(...parts: Array) {
+ return parts.filter(Boolean).join(' ')
+}
+
+export type ProgressVariant = 'default' | 'success' | 'warning' | 'danger' | 'info'
+export type ProgressSize = 'sm' | 'md' | 'lg'
+
+interface ProgressBarProps {
+ value: number // 0-100
+ max?: number
+ variant?: ProgressVariant
+ size?: ProgressSize
+ showLabel?: boolean
+ label?: string
+ className?: string
+ animated?: boolean
+}
+
+const variantStyles: Record = {
+ default: 'bg-base-500',
+ success: 'bg-brand-500',
+ warning: 'bg-amber-500',
+ danger: 'bg-danger-500',
+ info: 'bg-ocean-500',
+}
+
+const sizeStyles: Record = {
+ sm: 'h-1',
+ md: 'h-2',
+ lg: 'h-3',
+}
+
+export function ProgressBar({
+ value,
+ max = 100,
+ variant = 'default',
+ size = 'md',
+ showLabel = false,
+ label,
+ className,
+ animated = false,
+}: ProgressBarProps) {
+ const percentage = Math.min(100, Math.max(0, (value / max) * 100))
+
+ return (
+
+ {(showLabel || label) && (
+
+ {label && {label}}
+ {showLabel && (
+
+ {percentage.toFixed(1)}%
+
+ )}
+
+ )}
+
+
+ )
+}
+
+// Confidence bar with color gradient based on value
+interface ConfidenceBarProps {
+ value: number // 0-1
+ size?: ProgressSize
+ showLabel?: boolean
+ className?: string
+}
+
+export function ConfidenceBar({
+ value,
+ size = 'md',
+ showLabel = true,
+ className,
+}: ConfidenceBarProps) {
+ const percentage = value * 100
+
+ // Determine variant based on confidence level
+ let variant: ProgressVariant = 'danger'
+ if (percentage >= 80) variant = 'success'
+ else if (percentage >= 60) variant = 'info'
+ else if (percentage >= 40) variant = 'warning'
+
+ return (
+
+ )
+}
+
+// Forest coverage bar with specific styling
+interface CoverageBarProps {
+ value: number // 0-100
+ type?: 'forest' | 'ice' | 'water' | 'flood'
+ size?: ProgressSize
+ showLabel?: boolean
+ className?: string
+}
+
+const coverageVariants: Record = {
+ forest: 'success',
+ ice: 'info',
+ water: 'info',
+ flood: 'warning',
+}
+
+const coverageLabels: Record = {
+ forest: 'Forest Coverage',
+ ice: 'Ice Extent',
+ water: 'Water Coverage',
+ flood: 'Flooded Area',
+}
+
+export function CoverageBar({
+ value,
+ type = 'forest',
+ size = 'md',
+ showLabel = true,
+ className,
+}: CoverageBarProps) {
+ return (
+
+ )
+}
diff --git a/frontend/src/components/ui/SkeletonCard.tsx b/frontend/src/components/ui/SkeletonCard.tsx
new file mode 100644
index 0000000..c646af2
--- /dev/null
+++ b/frontend/src/components/ui/SkeletonCard.tsx
@@ -0,0 +1,25 @@
+export function SkeletonCard() {
+ return (
+
+ )
+}
+
+export function SkeletonRow() {
+ return (
+
+ )
+}
diff --git a/frontend/src/components/ui/StatusBadge.tsx b/frontend/src/components/ui/StatusBadge.tsx
new file mode 100644
index 0000000..e824a8f
--- /dev/null
+++ b/frontend/src/components/ui/StatusBadge.tsx
@@ -0,0 +1,39 @@
+import { CheckCircle, XCircle, Clock } from 'lucide-react'
+
+export type RunStatus = 'running' | 'completed' | 'failed' | 'pending'
+
+const config: Record = {
+ completed: {
+ label: 'Completed',
+ classes: 'bg-green-950/60 text-green-400 border-green-700/40',
+ icon: ,
+ },
+ failed: {
+ label: 'Failed',
+ classes: 'bg-red-950/60 text-red-400 border-red-700/40',
+ icon: ,
+ },
+ running: {
+ label: 'Running',
+ classes: 'bg-amber-950/60 text-amber-400 border-amber-700/40',
+ icon: ,
+ },
+ pending: {
+ label: 'Pending',
+ classes: 'bg-zinc-900/60 text-zinc-400 border-zinc-700/40',
+ icon: ,
+ },
+}
+
+export function StatusBadge({ status }: { status: RunStatus | string }) {
+ const s = (config[status as RunStatus] ?? config.pending)
+ return (
+
+ {s.icon}
+ {s.label}
+
+ )
+}
diff --git a/frontend/src/components/ui/Tooltip.tsx b/frontend/src/components/ui/Tooltip.tsx
new file mode 100644
index 0000000..8e13884
--- /dev/null
+++ b/frontend/src/components/ui/Tooltip.tsx
@@ -0,0 +1,116 @@
+import { ReactNode, useState } from 'react'
+
+function cx(...parts: Array) {
+ return parts.filter(Boolean).join(' ')
+}
+
+export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right'
+
+interface TooltipProps {
+ content: ReactNode
+ children: ReactNode
+ position?: TooltipPosition
+ className?: string
+ delay?: number
+}
+
+const positionStyles: Record = {
+ top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
+ bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
+ left: 'right-full top-1/2 -translate-y-1/2 mr-2',
+ right: 'left-full top-1/2 -translate-y-1/2 ml-2',
+}
+
+const arrowStyles: Record = {
+ top: 'top-full left-1/2 -translate-x-1/2 border-t-base-700 border-x-transparent border-b-transparent',
+ bottom: 'bottom-full left-1/2 -translate-x-1/2 border-b-base-700 border-x-transparent border-t-transparent',
+ left: 'left-full top-1/2 -translate-y-1/2 border-l-base-700 border-y-transparent border-r-transparent',
+ right: 'right-full top-1/2 -translate-y-1/2 border-r-base-700 border-y-transparent border-l-transparent',
+}
+
+export function Tooltip({
+ content,
+ children,
+ position = 'top',
+ className,
+ delay = 200,
+}: TooltipProps) {
+ const [visible, setVisible] = useState(false)
+ const [timeoutId, setTimeoutId] = useState(null)
+
+ const showTooltip = () => {
+ const id = setTimeout(() => setVisible(true), delay)
+ setTimeoutId(id)
+ }
+
+ const hideTooltip = () => {
+ if (timeoutId) {
+ clearTimeout(timeoutId)
+ setTimeoutId(null)
+ }
+ setVisible(false)
+ }
+
+ return (
+
+ {children}
+ {visible && (
+
+ {content}
+
+
+ )}
+
+ )
+}
+
+// Info icon with tooltip for explanations
+interface InfoTooltipProps {
+ content: ReactNode
+ position?: TooltipPosition
+}
+
+export function InfoTooltip({ content, position = 'top' }: InfoTooltipProps) {
+ return (
+
+
+
+ )
+}
diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts
new file mode 100644
index 0000000..a2672cf
--- /dev/null
+++ b/frontend/src/components/ui/index.ts
@@ -0,0 +1,11 @@
+export { Card, CompactCard } from './Card'
+export type { CardVariant } from './Card'
+
+export { Badge, StatusBadge, SeverityBadge, AnalysisTypeBadge } from './Badge'
+export type { BadgeVariant, BadgeSize, RunStatus, AlertSeverity, AnalysisType } from './Badge'
+
+export { ProgressBar, ConfidenceBar, CoverageBar } from './ProgressBar'
+export type { ProgressVariant, ProgressSize } from './ProgressBar'
+
+export { Tooltip, InfoTooltip } from './Tooltip'
+export type { TooltipPosition } from './Tooltip'
diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts
new file mode 100644
index 0000000..17fbb71
--- /dev/null
+++ b/frontend/src/constants.ts
@@ -0,0 +1,35 @@
+/**
+ * Application constants for ClimateVision frontend
+ */
+
+// API Configuration
+export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
+export const API_TIMEOUT = 30000;
+
+// Map Configuration
+export const DEFAULT_MAP_CENTER: [number, number] = [9.0820, 8.6753]; // Nigeria center
+export const DEFAULT_MAP_ZOOM = 6;
+export const MAX_BBOX_AREA_KM2 = 10000;
+
+// Analysis Types
+export const ANALYSIS_TYPES = {
+ DEFORESTATION: 'deforestation',
+ LAND_COVER: 'land_cover',
+ CARBON: 'carbon_estimation',
+} as const;
+
+// Polling Configuration
+export const RUN_POLL_INTERVAL_MS = 5000;
+export const MAX_POLL_ATTEMPTS = 120; // 10 minutes max
+
+// UI Constants
+export const TOAST_DURATION_MS = 5000;
+export const DEBOUNCE_DELAY_MS = 300;
+
+// Status Colors
+export const STATUS_COLORS = {
+ pending: '#f59e0b',
+ running: '#3b82f6',
+ completed: '#10b981',
+ failed: '#ef4444',
+} as const;
diff --git a/frontend/src/contexts/AppContext.tsx b/frontend/src/contexts/AppContext.tsx
new file mode 100644
index 0000000..6882e3c
--- /dev/null
+++ b/frontend/src/contexts/AppContext.tsx
@@ -0,0 +1,39 @@
+import { createContext, useContext, useState, useCallback } from 'react'
+import type { AnalysisType } from '../api'
+
+interface AppContextValue {
+ theme: 'dark' | 'light'
+ toggleTheme: () => void
+ defaultAnalysisType: AnalysisType
+ setDefaultAnalysisType: (t: AnalysisType) => void
+ googleMapsApiKey: string
+ apiBaseUrl: string
+}
+
+const AppContext = createContext(null)
+
+export function useApp() {
+ const ctx = useContext(AppContext)
+ if (!ctx) throw new Error('useApp must be inside AppProvider')
+ return ctx
+}
+
+export function AppProvider({ children }: { children: React.ReactNode }) {
+ const [theme, setTheme] = useState<'dark' | 'light'>('dark')
+ const [defaultAnalysisType, setDefaultAnalysisType] = useState('deforestation')
+
+ const toggleTheme = useCallback(() => {
+ setTheme((t) => (t === 'dark' ? 'light' : 'dark'))
+ }, [])
+
+ const googleMapsApiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY ?? ''
+ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000'
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/frontend/src/contexts/ToastContext.tsx b/frontend/src/contexts/ToastContext.tsx
new file mode 100644
index 0000000..1b92ca8
--- /dev/null
+++ b/frontend/src/contexts/ToastContext.tsx
@@ -0,0 +1,107 @@
+import { createContext, useContext, useState, useCallback, useRef } from 'react'
+import { CheckCircle, XCircle, AlertTriangle, Info, X } from 'lucide-react'
+
+export type ToastType = 'success' | 'error' | 'warning' | 'info'
+
+export interface Toast {
+ id: string
+ type: ToastType
+ message: string
+ action?: { label: string; onClick: () => void }
+}
+
+interface ToastContextValue {
+ toasts: Toast[]
+ showToast: (type: ToastType, message: string, action?: Toast['action']) => void
+ dismissToast: (id: string) => void
+}
+
+const ToastContext = createContext(null)
+
+export function useToast() {
+ const ctx = useContext(ToastContext)
+ if (!ctx) throw new Error('useToast must be used inside ToastProvider')
+ return ctx
+}
+
+const icons: Record = {
+ success: CheckCircle,
+ error: XCircle,
+ warning: AlertTriangle,
+ info: Info,
+}
+
+const styles: Record = {
+ success: 'border-green-500/40 bg-green-950/80 text-green-100',
+ error: 'border-red-500/40 bg-red-950/80 text-red-100',
+ warning: 'border-amber-500/40 bg-amber-950/80 text-amber-100',
+ info: 'border-blue-500/40 bg-blue-950/80 text-blue-100',
+}
+
+const iconStyles: Record = {
+ success: 'text-green-400',
+ error: 'text-red-400',
+ warning: 'text-amber-400',
+ info: 'text-blue-400',
+}
+
+function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
+ const Icon = icons[toast.type]
+ return (
+
+
+ {toast.message}
+ {toast.action && (
+
+ )}
+
+
+ )
+}
+
+export function ToastProvider({ children }: { children: React.ReactNode }) {
+ const [toasts, setToasts] = useState([])
+ const timers = useRef>>({})
+
+ const dismissToast = useCallback((id: string) => {
+ setToasts((prev) => prev.filter((t) => t.id !== id))
+ clearTimeout(timers.current[id])
+ delete timers.current[id]
+ }, [])
+
+ const showToast = useCallback(
+ (type: ToastType, message: string, action?: Toast['action']) => {
+ const id = Math.random().toString(36).slice(2)
+ setToasts((prev) => [...prev, { id, type, message, action }])
+ timers.current[id] = setTimeout(() => dismissToast(id), 5000)
+ },
+ [dismissToast],
+ )
+
+ return (
+
+ {children}
+
+ {toasts.map((t) => (
+
+ dismissToast(t.id)} />
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/hooks/useGeocoding.ts b/frontend/src/hooks/useGeocoding.ts
new file mode 100644
index 0000000..d367285
--- /dev/null
+++ b/frontend/src/hooks/useGeocoding.ts
@@ -0,0 +1,64 @@
+import { useState, useEffect, useRef } from 'react'
+
+const CACHE_KEY = 'cv_geocode_cache'
+
+function loadCache(): Record {
+ try {
+ return JSON.parse(localStorage.getItem(CACHE_KEY) ?? '{}')
+ } catch {
+ return {}
+ }
+}
+
+function saveCache(cache: Record) {
+ try {
+ localStorage.setItem(CACHE_KEY, JSON.stringify(cache))
+ } catch {}
+}
+
+export function useGeocoding(bbox: number[] | null | undefined, apiKey: string) {
+ const [regionName, setRegionName] = useState(null)
+ const cacheRef = useRef>(loadCache())
+
+ useEffect(() => {
+ if (!bbox || bbox.length !== 4 || !apiKey || apiKey === 'YOUR_GOOGLE_MAPS_API_KEY_HERE') return
+
+ const lat = (bbox[1] + bbox[3]) / 2
+ const lon = (bbox[0] + bbox[2]) / 2
+ const cacheKey = `${lat.toFixed(3)},${lon.toFixed(3)}`
+
+ if (cacheRef.current[cacheKey]) {
+ setRegionName(cacheRef.current[cacheKey])
+ return
+ }
+
+ const url = `https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lon}&key=${apiKey}`
+ fetch(url)
+ .then((r) => r.json())
+ .then((data) => {
+ const result = data.results?.[0]
+ if (!result) return
+ // Find locality or administrative area
+ const locality = result.address_components?.find((c: { types: string[] }) =>
+ c.types.includes('locality'),
+ )
+ const admin = result.address_components?.find((c: { types: string[] }) =>
+ c.types.includes('administrative_area_level_1'),
+ )
+ const country = result.address_components?.find((c: { types: string[] }) =>
+ c.types.includes('country'),
+ )
+ const name = [locality?.short_name, admin?.short_name, country?.short_name]
+ .filter(Boolean)
+ .join(', ')
+ if (name) {
+ cacheRef.current[cacheKey] = name
+ saveCache(cacheRef.current)
+ setRegionName(name)
+ }
+ })
+ .catch(() => {})
+ }, [bbox, apiKey])
+
+ return regionName
+}
diff --git a/frontend/src/hooks/useRunPolling.ts b/frontend/src/hooks/useRunPolling.ts
new file mode 100644
index 0000000..f076408
--- /dev/null
+++ b/frontend/src/hooks/useRunPolling.ts
@@ -0,0 +1,33 @@
+import { useEffect, useRef } from 'react'
+import type { Run } from '../api'
+
+export function useRunPolling(
+ runs: Run[],
+ fetchRuns: () => void,
+ onCompleted?: (run: Run) => void,
+) {
+ const prevRunsRef = useRef