622 lines
14 KiB
Markdown
622 lines
14 KiB
Markdown
# Plan 03: React Frontend Architecture
|
||
|
||
**Status**: Pending Implementation
|
||
**Date**: 2026-02-27
|
||
|
||
---
|
||
|
||
## Objective
|
||
|
||
Build a React-based frontend that enables users to:
|
||
1. Authenticate via JWT
|
||
2. Select Area of Interest (AOI) on an interactive map
|
||
3. Configure job parameters (year, model)
|
||
4. Submit inference jobs to the API
|
||
5. View real-time job status
|
||
6. Display results as tiled map layers
|
||
7. Download result GeoTIFFs
|
||
|
||
---
|
||
|
||
## 1. Architecture Overview
|
||
|
||
```mermaid
|
||
graph TD
|
||
A[React Frontend] -->|HTTPS| B[Ingress/Nginx]
|
||
B -->|Proxy| C[FastAPI Backend]
|
||
B -->|Proxy| D[TiTiler Tiles]
|
||
|
||
C -->|JWT| E[Auth Handler]
|
||
C -->|RQ| F[Redis Queue]
|
||
F --> G[Worker]
|
||
G -->|S3| H[MinIO]
|
||
|
||
D -->|Read COG| H
|
||
|
||
C -->|Presigned URL| A
|
||
```
|
||
|
||
## 2. Page Structure
|
||
|
||
### 2.1 Routes
|
||
|
||
| Path | Page | Description |
|
||
|------|------|-------------|
|
||
| `/` | Landing | Login form, demo info |
|
||
| `/dashboard` | Main App | Map + job submission |
|
||
| `/jobs` | Job List | User's job history |
|
||
| `/jobs/[id]` | Job Detail | Result view + download |
|
||
| `/admin` | Admin | Dataset upload, retraining |
|
||
|
||
### 2.2 Dashboard Layout
|
||
|
||
```tsx
|
||
// app/dashboard/page.tsx
|
||
export default function DashboardPage() {
|
||
return (
|
||
<div className="flex h-screen">
|
||
{/* Sidebar */}
|
||
<aside className="w-80 bg-white border-r p-4 flex flex-col">
|
||
<h1 className="text-xl font-bold mb-4">GeoCrop</h1>
|
||
|
||
{/* Job Form */}
|
||
<JobForm />
|
||
|
||
{/* Job Status */}
|
||
<JobStatus />
|
||
</aside>
|
||
|
||
{/* Map Area */}
|
||
<main className="flex-1 relative">
|
||
<MapView center={[-19.0, 29.0]} zoom={8}>
|
||
<LayerSwitcher />
|
||
<Legend />
|
||
</MapView>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Tech Stack
|
||
|
||
| Layer | Technology |
|
||
|-------|------------|
|
||
| Framework | Next.js 14 (App Router) |
|
||
| UI Library | Tailwind CSS + shadcn/ui |
|
||
| Maps | Leaflet + react-leaflet |
|
||
| State | Zustand |
|
||
| API Client | TanStack Query (React Query) |
|
||
| Forms | React Hook Form + Zod |
|
||
|
||
---
|
||
|
||
## 3. Project Structure
|
||
|
||
```
|
||
apps/web/
|
||
├── app/
|
||
│ ├── layout.tsx # Root layout with auth provider
|
||
│ ├── page.tsx # Landing/Login page
|
||
│ ├── dashboard/
|
||
│ │ └── page.tsx # Main app page
|
||
│ ├── jobs/
|
||
│ │ ├── page.tsx # Job list
|
||
│ │ └── [id]/page.tsx # Job detail/result
|
||
│ └── admin/
|
||
│ └── page.tsx # Admin panel
|
||
├── components/
|
||
│ ├── ui/ # shadcn components
|
||
│ ├── map/
|
||
│ │ ├── MapView.tsx # Main map component
|
||
│ │ ├── AoiSelector.tsx # Circle/polygon selection
|
||
│ │ ├── LayerSwitcher.tsx
|
||
│ │ └── Legend.tsx
|
||
│ ├── job/
|
||
│ │ ├── JobForm.tsx # Job submission form
|
||
│ │ ├── JobStatus.tsx # Status polling
|
||
│ │ └── JobResults.tsx # Results display
|
||
│ └── auth/
|
||
│ ├── LoginForm.tsx
|
||
│ └── ProtectedRoute.tsx
|
||
├── lib/
|
||
│ ├── api.ts # API client
|
||
│ ├── auth.ts # Auth utilities
|
||
│ ├── map-utils.ts # Map helpers
|
||
│ └── constants.ts # App constants
|
||
├── stores/
|
||
│ └── useAppStore.ts # Zustand store
|
||
├── types/
|
||
│ └── index.ts # TypeScript types
|
||
└── public/
|
||
└── zimbabwe.geojson # Zimbabwe boundary
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Key Components
|
||
|
||
### 4.1 Authentication Flow
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant User
|
||
participant Frontend
|
||
participant API
|
||
participant Redis
|
||
|
||
User->>Frontend: Enter email/password
|
||
Frontend->>API: POST /auth/login
|
||
API->>Redis: Verify credentials
|
||
Redis-->>API: User data
|
||
API-->>Frontend: JWT token
|
||
Frontend->>Frontend: Store JWT in localStorage
|
||
Frontend->>User: Redirect to dashboard
|
||
```
|
||
|
||
### 4.2 Job Submission Flow
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant User
|
||
participant Frontend
|
||
participant API
|
||
participant Worker
|
||
participant MinIO
|
||
|
||
User->>Frontend: Submit AOI + params
|
||
Frontend->>API: POST /jobs
|
||
API->>Redis: Enqueue job
|
||
API-->>Frontend: job_id
|
||
Frontend->>Frontend: Start polling
|
||
Worker->>Worker: Process (5-15 min)
|
||
Worker->>MinIO: Upload COG
|
||
Worker->>Redis: Update status
|
||
Frontend->>API: GET /jobs/{id}
|
||
API-->>Frontend: Status + download URL
|
||
Frontend->>User: Show result
|
||
```
|
||
|
||
### 4.3 Data Flow
|
||
|
||
1. User logs in → stores JWT
|
||
2. User selects AOI + year + model → POST /jobs
|
||
3. UI polls GET /jobs/{id}
|
||
4. When done: receives layer URLs (tiles) and download signed URL
|
||
|
||
---
|
||
|
||
## 5. Component Details
|
||
|
||
### 5.1 MapView Component
|
||
|
||
```tsx
|
||
// components/map/MapView.tsx
|
||
'use client';
|
||
|
||
import { MapContainer, TileLayer, useMap } from 'react-leaflet';
|
||
import { useEffect } from 'react';
|
||
import L from 'leaflet';
|
||
|
||
interface MapViewProps {
|
||
center: [number, number]; // [lat, lon] - Zimbabwe default
|
||
zoom: number;
|
||
children?: React.ReactNode;
|
||
}
|
||
|
||
export function MapView({ center, zoom, children }: MapViewProps) {
|
||
return (
|
||
<MapContainer
|
||
center={center}
|
||
zoom={zoom}
|
||
style={{ height: '100%', width: '100%' }}
|
||
className="rounded-lg"
|
||
>
|
||
{/* Base layer - OpenStreetMap */}
|
||
<TileLayer
|
||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||
/>
|
||
|
||
{/* Result layers from TiTiler - added dynamically */}
|
||
{children}
|
||
</MapContainer>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 5.2 AOI Selector
|
||
|
||
```tsx
|
||
// components/map/AoiSelector.tsx
|
||
'use client';
|
||
|
||
import { useMapEvents, Circle, CircleMarker } from 'react-leaflet';
|
||
import { useState, useCallback } from 'react';
|
||
import L from 'leaflet';
|
||
|
||
interface AoiSelectorProps {
|
||
onChange: (center: [number, number], radius: number) => void;
|
||
maxRadiusKm: number;
|
||
}
|
||
|
||
export function AoiSelector({ onChange, maxRadiusKm }: AoiSelectorProps) {
|
||
const [center, setCenter] = useState<[number, number] | null>(null);
|
||
const [radius, setRadius] = useState(1000); // meters
|
||
|
||
const map = useMapEvents({
|
||
click: (e) => {
|
||
const { lat, lng } = e.latlng;
|
||
setCenter([lat, lng]);
|
||
onChange([lat, lng], radius);
|
||
}
|
||
});
|
||
|
||
return (
|
||
<>
|
||
{center && (
|
||
<Circle
|
||
center={center}
|
||
radius={radius}
|
||
pathOptions={{
|
||
color: '#3b82f6',
|
||
fillColor: '#3b82f6',
|
||
fillOpacity: 0.2
|
||
}}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 5.3 Job Status Polling
|
||
|
||
```tsx
|
||
// components/job/JobStatus.tsx
|
||
'use client';
|
||
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { useEffect, useState } from 'react';
|
||
|
||
interface JobStatusProps {
|
||
jobId: string;
|
||
onComplete: (result: any) => void;
|
||
}
|
||
|
||
export function JobStatus({ jobId, onComplete }: JobStatusProps) {
|
||
const [status, setStatus] = useState('queued');
|
||
|
||
// Poll for status updates
|
||
const { data, isLoading } = useQuery({
|
||
queryKey: ['job', jobId],
|
||
queryFn: () => fetchJobStatus(jobId),
|
||
refetchInterval: (query) => {
|
||
const status = query.state.data?.status;
|
||
if (status === 'finished' || status === 'failed') {
|
||
return false; // Stop polling
|
||
}
|
||
return 5000; // Poll every 5 seconds
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (data?.status === 'finished') {
|
||
onComplete(data.result);
|
||
}
|
||
}, [data]);
|
||
|
||
const steps = [
|
||
{ id: 'queued', label: 'Queued', icon: '⏳' },
|
||
{ id: 'processing', label: 'Processing', icon: '⚙️' },
|
||
{ id: 'finished', label: 'Complete', icon: '✅' },
|
||
];
|
||
|
||
// ... render progress steps
|
||
}
|
||
```
|
||
|
||
### 5.4 Layer Switcher
|
||
|
||
```tsx
|
||
// components/map/LayerSwitcher.tsx
|
||
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { TileLayer } from 'react-leaflet';
|
||
|
||
interface Layer {
|
||
id: string;
|
||
name: string;
|
||
urlTemplate: string;
|
||
visible: boolean;
|
||
}
|
||
|
||
interface LayerSwitcherProps {
|
||
layers: Layer[];
|
||
onToggle: (id: string) => void;
|
||
}
|
||
|
||
export function LayerSwitcher({ layers, onToggle }: LayerSwitcherProps) {
|
||
const [activeLayer, setActiveLayer] = useState('refined');
|
||
|
||
return (
|
||
<div className="absolute top-4 right-4 bg-white p-3 rounded-lg shadow-md z-[1000]">
|
||
<h3 className="font-semibold mb-2">Layers</h3>
|
||
<div className="space-y-2">
|
||
{layers.map(layer => (
|
||
<label key={layer.id} className="flex items-center gap-2">
|
||
<input
|
||
type="radio"
|
||
name="layer"
|
||
checked={activeLayer === layer.id}
|
||
onChange={() => setActiveLayer(layer.id)}
|
||
/>
|
||
<span>{layer.name}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6. State Management
|
||
|
||
### 6.1 Zustand Store
|
||
|
||
```typescript
|
||
// stores/useAppStore.ts
|
||
import { create } from 'zustand';
|
||
|
||
interface AppState {
|
||
// Auth
|
||
user: User | null;
|
||
token: string | null;
|
||
isAuthenticated: boolean;
|
||
setAuth: (user: User, token: string) => void;
|
||
logout: () => void;
|
||
|
||
// Job
|
||
currentJob: Job | null;
|
||
setCurrentJob: (job: Job | null) => void;
|
||
|
||
// Map
|
||
aoiCenter: [number, number] | null;
|
||
aoiRadius: number;
|
||
setAoi: (center: [number, number], radius: number) => void;
|
||
selectedYear: number;
|
||
setYear: (year: number) => void;
|
||
selectedModel: string;
|
||
setModel: (model: string) => void;
|
||
}
|
||
|
||
export const useAppStore = create<AppState>((set) => ({
|
||
// Auth
|
||
user: null,
|
||
token: null,
|
||
isAuthenticated: false,
|
||
setAuth: (user, token) => set({ user, token, isAuthenticated: true }),
|
||
logout: () => set({ user: null, token: null, isAuthenticated: false }),
|
||
|
||
// Job
|
||
currentJob: null,
|
||
setCurrentJob: (job) => set({ currentJob: job }),
|
||
|
||
// Map
|
||
aoiCenter: null,
|
||
aoiRadius: 1000,
|
||
setAoi: (center, radius) => set({ aoiCenter: center, aoiRadius: radius }),
|
||
selectedYear: new Date().getFullYear(),
|
||
setYear: (year) => set({ selectedYear: year }),
|
||
selectedModel: 'lightgbm',
|
||
setModel: (model) => set({ selectedModel: model }),
|
||
}));
|
||
```
|
||
|
||
---
|
||
|
||
## 7. API Client
|
||
|
||
### 7.1 API Service
|
||
|
||
```typescript
|
||
// lib/api.ts
|
||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://api.portfolio.techarvest.co.zw';
|
||
|
||
class ApiClient {
|
||
private token: string | null = null;
|
||
|
||
setToken(token: string) {
|
||
this.token = token;
|
||
}
|
||
|
||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||
const headers: HeadersInit = {
|
||
'Content-Type': 'application/json',
|
||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
||
...options.headers,
|
||
};
|
||
|
||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||
...options,
|
||
headers,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`API error: ${response.statusText}`);
|
||
}
|
||
|
||
return response.json();
|
||
}
|
||
|
||
// Auth
|
||
async login(email: string, password: string) {
|
||
const formData = new URLSearchParams();
|
||
formData.append('username', email);
|
||
formData.append('password', password);
|
||
|
||
const response = await fetch(`${API_BASE}/auth/login`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: formData,
|
||
});
|
||
|
||
return response.json();
|
||
}
|
||
|
||
// Jobs
|
||
async createJob(jobData: JobRequest) {
|
||
return this.request<JobResponse>('/jobs', {
|
||
method: 'POST',
|
||
body: JSON.stringify(jobData),
|
||
});
|
||
}
|
||
|
||
async getJobStatus(jobId: string) {
|
||
return this.request<JobStatus>(`/jobs/${jobId}`);
|
||
}
|
||
|
||
async getJobResult(jobId: string) {
|
||
return this.request<JobResult>(`/jobs/${jobId}/result`);
|
||
}
|
||
|
||
// Models
|
||
async getModels() {
|
||
return this.request<Model[]>('/models');
|
||
}
|
||
}
|
||
|
||
export const api = new ApiClient();
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Pages & Routes
|
||
|
||
### 8.1 Route Structure
|
||
|
||
| Path | Page | Description |
|
||
|------|------|-------------|
|
||
| `/` | Landing | Login form, demo info |
|
||
| `/dashboard` | Main App | Map + job submission |
|
||
| `/jobs` | Job List | User's job history |
|
||
| `/jobs/[id]` | Job Detail | Result view + download |
|
||
| `/admin` | Admin | Dataset upload, retraining |
|
||
|
||
### 8.2 Dashboard Page Layout
|
||
|
||
```tsx
|
||
// app/dashboard/page.tsx
|
||
export default function DashboardPage() {
|
||
return (
|
||
<div className="flex h-screen">
|
||
{/* Sidebar */}
|
||
<aside className="w-80 bg-white border-r p-4 flex flex-col">
|
||
<h1 className="text-xl font-bold mb-4">GeoCrop</h1>
|
||
|
||
{/* Job Form */}
|
||
<JobForm />
|
||
|
||
{/* Job Status */}
|
||
<JobStatus />
|
||
</aside>
|
||
|
||
{/* Map Area */}
|
||
<main className="flex-1 relative">
|
||
<MapView center={[-19.0, 29.0]} zoom={8}>
|
||
<LayerSwitcher />
|
||
<Legend />
|
||
</MapView>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Environment Variables
|
||
|
||
```bash
|
||
# .env.local
|
||
NEXT_PUBLIC_API_URL=https://api.portfolio.techarvest.co.zw
|
||
NEXT_PUBLIC_TILES_URL=https://tiles.portfolio.techarvest.co.zw
|
||
NEXT_PUBLIC_MAP_CENTER=-19.0,29.0
|
||
NEXT_PUBLIC_MAP_ZOOM=8
|
||
|
||
# JWT Secret (for token validation)
|
||
JWT_SECRET=your-secret-here
|
||
```
|
||
|
||
---
|
||
|
||
## 10. Implementation Checklist
|
||
|
||
- [ ] Set up Next.js project with TypeScript
|
||
- [ ] Install dependencies (leaflet, react-leaflet, tailwind, zustand, react-query)
|
||
- [ ] Configure Tailwind CSS
|
||
- [ ] Create auth components (LoginForm, ProtectedRoute)
|
||
- [ ] Create API client
|
||
- [ ] Implement Zustand store
|
||
- [ ] Build MapView component
|
||
- [ ] Build AoiSelector component
|
||
- [ ] Build JobForm component
|
||
- [ ] Build JobStatus component with polling
|
||
- [ ] Build LayerSwitcher component
|
||
- [ ] Build Legend component
|
||
- [ ] Create dashboard page layout
|
||
- [ ] Create job detail page
|
||
- [ ] Add Zimbabwe boundary GeoJSON
|
||
- [ ] Test end-to-end flow
|
||
|
||
### 11.1 UX Constraints
|
||
|
||
- Zimbabwe-only
|
||
- Max radius 5km
|
||
- Summer season fixed (Sep–May)
|
||
|
||
---
|
||
|
||
## 11. Key Constraints
|
||
|
||
### 11.1 AOI Validation
|
||
|
||
- Max radius: 5km (per API)
|
||
- Must be within Zimbabwe bounds
|
||
- Lon: 25.2 to 33.1, Lat: -22.5 to -15.6
|
||
|
||
### 11.2 Year Range
|
||
|
||
- Available: 2015 to present
|
||
- Must match available DW baselines
|
||
|
||
### 11.3 Models
|
||
|
||
- Default: `lightgbm`
|
||
- Available: `randomforest`, `xgboost`, `catboost`
|
||
|
||
### 11.4 Rate Limits
|
||
|
||
- 5 jobs per 24 hours per user
|
||
- Global: 2 concurrent jobs
|
||
|
||
---
|
||
|
||
## 12. Next Steps
|
||
|
||
After implementation approval:
|
||
|
||
1. Initialize Next.js project
|
||
2. Install and configure dependencies
|
||
3. Build authentication flow
|
||
4. Create map components
|
||
5. Build job submission and status UI
|
||
6. Add layer switching and legend
|
||
7. Test with mock data
|
||
8. Deploy to cluster
|