geocrop-platform./plan/03_react_frontend.md

14 KiB
Raw Permalink Blame History

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

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

  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

// 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='&copy; <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 (SepMay)

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