14 KiB
14 KiB
Plan 03: React Frontend Architecture
Status: Pending Implementation
Date: 2026-02-27
Objective
Build a React-based frontend that enables users to:
- Authenticate via JWT
- Select Area of Interest (AOI) on an interactive map
- Configure job parameters (year, model)
- Submit inference jobs to the API
- View real-time job status
- Display results as tiled map layers
- Download result GeoTIFFs
1. Architecture Overview
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
// 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
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
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
- User logs in → stores JWT
- User selects AOI + year + model → POST /jobs
- UI polls GET /jobs/{id}
- When done: receives layer URLs (tiles) and download signed URL
5. Component Details
5.1 MapView Component
// 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
// 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
// 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
// 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
// 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
// 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
// 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
# .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:
- Initialize Next.js project
- Install and configure dependencies
- Build authentication flow
- Create map components
- Build job submission and status UI
- Add layer switching and legend
- Test with mock data
- Deploy to cluster