Files
rfcp/docs/RFCP-ARCHITECTURE.md

39 KiB
Raw Blame History

RFCP Architecture Documentation

Project: RFCP (RF Coverage Planning) for Tactical Communications
Developer: Олег
Version: 1.0
Last Updated: January 30, 2025
Status: Frontend Complete, Backend In Planning


📋 Table of Contents

  1. Project Overview
  2. System Architecture
  3. Frontend Architecture
  4. Backend Architecture
  5. Data Models
  6. RF Calculation Engine
  7. Geographic Rendering
  8. State Management
  9. Deployment Architecture
  10. Development History
  11. Future Roadmap

🎯 Project Overview

Vision

RFCP is a professional RF coverage planning tool designed for tactical military communications. It enables:

  • Multi-site LTE network planning
  • Realistic RF coverage visualization
  • Geographic-scale heatmap rendering
  • Multi-sector site configurations

Key Principles

┌────────────────────────────────────────────────────────────┐
│  🎯 Professional Grade  - Production-ready code quality    │
│  🗺️ Geographic Accuracy - True-scale visualization         │
│  ⚡ Performance First    - Web Workers, tile caching       │
│  🔐 Security Focused     - VPN-only access, no public      │
│  📐 Military Precision   - Realistic RF calculations       │
└────────────────────────────────────────────────────────────┘

Technology Stack

Frontend:

  • React 18.3+ (UI framework)
  • TypeScript 5.x (type safety)
  • Vite 5.x (build tool)
  • Leaflet 1.9+ (mapping library)
  • Zustand 4.x (state management)

Backend (Planned):

  • FastAPI 0.109+ (Python web framework)
  • MongoDB 7.x (document database)
  • Motor (async MongoDB driver)
  • NumPy/SciPy (scientific computing)

Infrastructure:

  • VPS: Hetzner Germany (10.10.10.1)
  • Reverse Proxy: Caddy 2.x
  • VPN: WireGuard mesh (10.10.0.0/16)
  • Systemd services

🏗️ System Architecture

High-Level Architecture

┌─────────────────────────────────────────────────────────────────────┐
│                         RFCP System                                  │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  ┌──────────────────────┐                  ┌──────────────────────┐ │
│  │   Frontend Layer     │                  │   Backend Layer      │ │
│  │   (React + Leaflet)  │◄────REST API────►│   (FastAPI + Mongo)  │ │
│  │   Port: 443 (HTTPS)  │                  │   Port: 8888 (HTTP)  │ │
│  └──────────┬───────────┘                  └──────────┬───────────┘ │
│             │                                         │              │
│             │                                         │              │
│  ┌──────────┴───────────┐                  ┌──────────┴───────────┐ │
│  │  Web Workers         │                  │  External APIs       │ │
│  │  - RF calculations   │                  │  - SRTM terrain      │ │
│  │  - Parallel compute  │                  │  - OpenStreetMap     │ │
│  └──────────────────────┘                  └──────────────────────┘ │
│                                                                      │
├─────────────────────────────────────────────────────────────────────┤
│                      Infrastructure Layer                            │
│                                                                      │
│  ┌──────────────────────┐    ┌──────────────────────┐              │
│  │  Caddy Reverse Proxy │    │  WireGuard VPN Mesh  │              │
│  │  - SSL termination   │    │  - 10.10.0.0/16      │              │
│  │  - Path routing      │    │  - Site isolation    │              │
│  └──────────────────────┘    └──────────────────────┘              │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

Network Topology

Internet (Public)
      │
      ▼
[Caddy Reverse Proxy]
      │
      ├──> rfcp.eliah.one ──> Frontend (static files)
      │
      └──> (future: api.rfcp.eliah.one ──> Backend API)
      
      
WireGuard VPN (10.10.0.0/16)
      │
      ├──> 10.10.10.1 (VPS A - Germany)
      │    ├──> Frontend: dist/
      │    ├──> Backend: :8888
      │    └──> MongoDB: :27017
      │
      └──> 10.10.11.1 (VPS B - Finland) [future expansion]

Access Control

Security Layer:
┌─────────────────────────────────────────────────────────────┐
│  Level 1: Public Internet                                   │
│  ├─> Caddy (HTTPS only)                                     │
│  └─> Frontend static files                                  │
│                                                              │
│  Level 2: WireGuard VPN (10.10.0.0/16)                      │
│  ├─> Backend API (internal only)                            │
│  ├─> MongoDB (internal only)                                │
│  └─> All admin interfaces                                   │
│                                                              │
│  Level 3: Future Auth (MAS OAuth)                           │
│  ├─> User authentication                                    │
│  ├─> Role-based access                                      │
│  └─> Project ownership                                      │
└─────────────────────────────────────────────────────────────┘

🎨 Frontend Architecture

Component Hierarchy

App.tsx (Root)
├── Map.tsx (Leaflet container)
│   ├── TileLayer (OpenStreetMap)
│   ├── SiteMarker[] (site locations)
│   ├── GeographicHeatmap (custom canvas overlay)
│   └── CoverageBoundary (polygon outline)
│
├── SitesPanel (left sidebar)
│   ├── SiteList
│   │   └── SiteCard[] (collapsible)
│   │       └── SectorList[] (Alpha, Beta, Gamma...)
│   ├── SiteModal (create/edit dialog)
│   └── BatchOperations (power, tilt, delete)
│
├── SettingsPanel (right sidebar)
│   ├── CoverageSettings
│   │   ├── NumberInput (radius, resolution)
│   │   └── ThresholdSliders (RSRP limits)
│   ├── MapSettings (base layer, opacity)
│   └── CalculateButton
│
├── CoverageStats (bottom panel)
│   ├── SignalQuality (excellent/good/fair/poor counts)
│   ├── CoverageArea (km²)
│   └── AverageRSRP
│
└── UIComponents (shared)
    ├── NumberInput (with slider)
    ├── ConfirmDialog (delete confirmation)
    ├── Toast (notifications)
    └── LoadingSpinner

Directory Structure

/opt/rfcp/frontend/
├── src/
│   ├── components/
│   │   ├── map/
│   │   │   ├── Map.tsx                    # Main map container
│   │   │   ├── SiteMarker.tsx             # Site location markers
│   │   │   ├── GeographicHeatmap.tsx      # Custom canvas renderer
│   │   │   ├── HeatmapTileRenderer.ts     # Tile generation logic
│   │   │   └── CoverageBoundary.tsx       # Coverage outline polygon
│   │   │
│   │   ├── panels/
│   │   │   ├── SitesPanel.tsx             # Site management panel
│   │   │   ├── SettingsPanel.tsx          # Coverage settings
│   │   │   ├── CoverageStats.tsx          # Statistics display
│   │   │   └── ExportPanel.tsx            # Data export
│   │   │
│   │   ├── ui/
│   │   │   ├── NumberInput.tsx            # Validated numeric input
│   │   │   ├── ConfirmDialog.tsx          # Confirmation modal
│   │   │   ├── Toast.tsx                  # Toast notifications
│   │   │   ├── Button.tsx                 # Styled button
│   │   │   └── Modal.tsx                  # Generic modal
│   │   │
│   │   └── modals/
│   │       └── SiteModal.tsx              # Site create/edit dialog
│   │
│   ├── store/
│   │   ├── sites.ts                       # Site state (Zustand)
│   │   ├── coverage.ts                    # Coverage state
│   │   ├── ui.ts                          # UI state (modals, etc)
│   │   └── settings.ts                    # App settings
│   │
│   ├── lib/
│   │   ├── calculator.ts                  # Coverage grid generation
│   │   ├── pathLoss.ts                    # RF propagation models
│   │   ├── antennaPattern.ts              # Directional patterns
│   │   └── geographic.ts                  # Lat/lon utilities
│   │
│   ├── utils/
│   │   ├── colorGradient.ts               # RSRP color mapping
│   │   ├── validation.ts                  # Input validation
│   │   ├── logger.ts                      # Logging utility
│   │   └── format.ts                      # Number formatting
│   │
│   ├── constants/
│   │   ├── rsrp-thresholds.ts             # Signal quality thresholds
│   │   ├── frequencies.ts                 # LTE frequency bands
│   │   └── defaults.ts                    # Default values
│   │
│   ├── hooks/
│   │   ├── useKeyboardShortcuts.ts        # Keyboard handlers
│   │   ├── useCoverageCalculation.ts      # Coverage computation
│   │   └── useMapBounds.ts                # Map viewport tracking
│   │
│   ├── types/
│   │   ├── site.ts                        # Site & Sector types
│   │   ├── coverage.ts                    # Coverage types
│   │   └── map.ts                         # Map types
│   │
│   ├── workers/
│   │   └── rf-worker.ts                   # Web Worker for RF calc
│   │
│   ├── App.tsx                            # Root component
│   ├── main.tsx                           # Entry point
│   └── index.css                          # Global styles
│
├── public/
│   └── workers/
│       └── rf-worker.js                   # Built worker script
│
├── docs/
│   └── devlog/
│       └── front/
│           ├── RFCP-Iteration1-*.md       # Development history
│           ├── RFCP-Iteration2-*.md
│           ├── ...
│           └── RFCP-Iteration10.6-*.md    # Latest iteration
│
├── dist/                                  # Production build
├── node_modules/
├── package.json
├── tsconfig.json
├── vite.config.ts
└── README.md

Key Design Patterns

1. State Management (Zustand)

// Centralized state with actions
export const useSitesStore = create<SitesStore>((set, get) => ({
  sites: [],
  selectedSiteId: null,
  
  // Actions
  addSite: (site) => set((state) => ({ 
    sites: [...state.sites, site] 
  })),
  
  updateSite: (id, updates) => set((state) => ({
    sites: state.sites.map(s => s.id === id ? { ...s, ...updates } : s)
  })),
  
  deleteSite: (id) => set((state) => ({
    sites: state.sites.filter(s => s.id !== id),
    selectedSiteId: state.selectedSiteId === id ? null : state.selectedSiteId
  })),
  
  selectSite: (id) => set({ selectedSiteId: id })
}));

2. Custom Hooks

// Encapsulate complex logic
export function useCoverageCalculation() {
  const sites = useSitesStore(s => s.sites);
  const settings = useCoverageStore(s => s.settings);
  const [isCalculating, setIsCalculating] = useState(false);
  
  const calculate = useCallback(async () => {
    setIsCalculating(true);
    try {
      const worker = new Worker('/workers/rf-worker.js');
      const points = await calculateCoverageGrid(sites, settings, worker);
      useCoverageStore.getState().setCoveragePoints(points);
    } finally {
      setIsCalculating(false);
    }
  }, [sites, settings]);
  
  return { calculate, isCalculating };
}

3. Keyboard Shortcuts

// Global keyboard handler with callbacks
export function useKeyboardShortcuts(callbacks: KeyboardCallbacks) {
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      // N = New Site
      if (e.key === 'n' || e.key === 'N') {
        callbacks.onNewSite?.();
      }
      
      // Delete = Delete selected
      if (e.key === 'Delete' && selectedSiteId) {
        callbacks.onDeleteRequest?.(selectedSiteId, siteName);
      }
      
      // Ctrl+Z = Undo
      if (e.ctrlKey && e.key === 'z') {
        callbacks.onUndo?.();
      }
      
      // ... more shortcuts
    };
    
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [callbacks]);
}

🖥️ Backend Architecture (Planned)

Technology Stack

┌────────────────────────────────────────────────────┐
│              Backend Technology Stack               │
├────────────────────────────────────────────────────┤
│                                                     │
│  Web Framework:  FastAPI 0.109+                    │
│  Database:       MongoDB 7.x                       │
│  DB Driver:      Motor (async)                     │
│  Validation:     Pydantic 2.x                      │
│  ASGI Server:    Uvicorn                           │
│                                                     │
│  RF Computing:   NumPy, SciPy                      │
│  Terrain Data:   SRTM (NASA elevation)             │
│  Building Data:  OpenStreetMap Overpass API        │
│                                                     │
└────────────────────────────────────────────────────┘

Service Architecture

Backend Services:
┌─────────────────────────────────────────────────────────────┐
│  API Layer (FastAPI)                                        │
│  ├── routes/                                                │
│  │   ├── projects.py     - CRUD for projects               │
│  │   ├── sites.py        - Site management                 │
│  │   ├── coverage.py     - Coverage calculation            │
│  │   └── terrain.py      - Terrain data endpoints          │
│  │                                                          │
│  ├── services/                                              │
│  │   ├── rf_calculator.py    - Path loss, LOS, Fresnel    │
│  │   ├── terrain_service.py  - SRTM data handling          │
│  │   ├── osm_service.py      - Building obstacles          │
│  │   └── cache_service.py    - Coverage caching            │
│  │                                                          │
│  └── models/                                                │
│      ├── project.py     - Pydantic models                  │
│      ├── site.py        - Site/Sector schemas              │
│      └── coverage.py    - Coverage data schemas            │
└─────────────────────────────────────────────────────────────┘

API Endpoints (Planned)

# Project Management
GET    /api/projects/current          # Get global project
PUT    /api/projects/current          # Update project
GET    /api/projects/current/sites    # Get all sites
PUT    /api/projects/current/sites    # Update sites
GET    /api/projects/current/settings # Get settings
PUT    /api/projects/current/settings # Update settings

# Coverage Calculation
POST   /api/coverage/calculate        # Trigger calculation
GET    /api/coverage/cache            # Get cached results

# Terrain Data (future)
GET    /api/terrain/elevation         # Get elevation at point
GET    /api/terrain/profile           # Get elevation profile
GET    /api/terrain/buildings         # Get buildings in area

# Export/Import (future)
GET    /api/export/json               # Export project as JSON
POST   /api/import/json               # Import project from JSON

Data Persistence

MongoDB Collections:
┌─────────────────────────────────────────────────────────────┐
│  rfcp (database)                                            │
│  ├── projects                                               │
│  │   └── { name, sites[], settings, created_at, ... }      │
│  │                                                          │
│  ├── coverage_cache                                         │
│  │   └── { project_name, calculated_at, points[], ... }    │
│  │                                                          │
│  └── terrain_cache (future)                                │
│      └── { tile_name, elevation_data, downloaded_at }      │
└─────────────────────────────────────────────────────────────┘

📊 Data Models

Core Types

// Site with multiple sectors
interface Site {
  id: string;
  name: string;
  lat: number;
  lon: number;
  sectors: Sector[];
}

// Individual sector (antenna)
interface Sector {
  id: string;
  name: string;          // "Alpha", "Beta", "Gamma", ...
  power: number;         // dBm (10-50)
  gain: number;          // dBi (0-25)
  height: number;        // meters (1-500)
  frequency: number;     // MHz (700-3800)
  azimuth: number;       // degrees (0-359)
  beamwidth: number;     // degrees (10-360)
  tilt: number;          // degrees (-20 to +20)
  antennaType: 'omnidirectional' | 'directional';
}

// Coverage calculation settings
interface CoverageSettings {
  radius: number;        // meters (1000-50000)
  resolution: number;    // meters (50-500)
  minSignal: number;     // dBm (-120 to -60)
  useTerrain: boolean;   // Enable terrain calculations
  useBuildings: boolean; // Enable building obstacles
}

// Coverage point (single grid cell)
interface CoveragePoint {
  lat: number;
  lon: number;
  rsrp: number;         // dBm
  siteId: string;       // Which site provides coverage
  sectorId: string;     // Which sector
}

// Signal quality categories
type SignalQuality = 'excellent' | 'good' | 'fair' | 'poor' | 'none';

RSRP Thresholds

// LTE RSRP (Reference Signal Received Power) ranges
export const RSRP_THRESHOLDS = {
  excellent: -80,  // >-80 dBm   - Full bars, HD video
  good: -95,       // -80 to -95  - Good voice, streaming
  fair: -105,      // -95 to -105 - Acceptable voice
  poor: -115,      // -105 to -115 - Marginal coverage
  // Below -115 dBm = no service
} as const;

// Color mapping (purple → orange gradient)
export const SIGNAL_COLORS = {
  excellent: '#ffb74d',  // Light orange
  good: '#ff9800',       // Orange
  fair: '#ff6f00',       // Dark orange
  poor: '#ab47bc',       // Light purple
  veryPoor: '#7b1fa2',   // Purple
  terrible: '#4a148c',   // Dark purple
  none: 'transparent'
} as const;

📡 RF Calculation Engine

Path Loss Models

1. Free Space Path Loss (FSPL)

function calculateFSPL(distanceKm: number, frequencyMHz: number): number {
  // FSPL(dB) = 20log10(d) + 20log10(f) + 32.44
  // where d is in km, f is in MHz
  return 20 * Math.log10(distanceKm) + 
         20 * Math.log10(frequencyMHz) + 
         32.44;
}

2. Okumura-Hata Model (currently used)

function calculateOkumuraHata(
  distanceKm: number,
  frequencyMHz: number,
  txHeightM: number,
  rxHeightM: number
): number {
  // Urban/suburban propagation model for 150-1500 MHz
  // L(dB) = 69.55 + 26.16log10(f) - 13.82log10(hb) - a(hm) + 
  //         (44.9 - 6.55log10(hb))log10(d)
  
  const a_hm = (1.1 * Math.log10(frequencyMHz) - 0.7) * rxHeightM - 
               (1.56 * Math.log10(frequencyMHz) - 0.8);
  
  return 69.55 + 
         26.16 * Math.log10(frequencyMHz) - 
         13.82 * Math.log10(txHeightM) - 
         a_hm + 
         (44.9 - 6.55 * Math.log10(txHeightM)) * Math.log10(distanceKm);
}

3. Terrain-Enhanced Model (backend, planned)

async def calculate_path_loss_with_terrain(
    site_lat: float, site_lon: float, site_height: float,
    point_lat: float, point_lon: float, point_height: float,
    frequency_mhz: float,
    terrain_service: TerrainService
) -> float:
    """
    Enhanced path loss with:
    - Terrain elevation profile
    - Line-of-sight check
    - Fresnel zone clearance
    - Diffraction loss over obstacles
    """
    
    # Base propagation model
    base_loss = calculate_okumura_hata(...)
    
    # Terrain factors
    has_los, clearance = await check_line_of_sight(...)
    if not has_los:
        # Add obstruction loss
        diffraction_loss = calculate_knife_edge_diffraction(clearance)
        base_loss += diffraction_loss
    
    # Fresnel zone clearance
    fresnel_pct = await check_fresnel_clearance(...)
    if fresnel_pct < 60:
        # Partial obstruction
        base_loss += (60 - fresnel_pct) / 60 * 10  # Up to 10 dB loss
    
    return base_loss

Antenna Pattern

Directional Antenna Gain:

function calculateDirectionalGain(
  azimuth: number,        // Antenna pointing direction
  beamwidth: number,      // 3dB beamwidth
  maxGain: number,        // Peak gain in dBi
  targetAzimuth: number   // Direction to target point
): number {
  // Calculate angle difference
  let angleDiff = Math.abs(targetAzimuth - azimuth);
  if (angleDiff > 180) angleDiff = 360 - angleDiff;
  
  // Gaussian-like pattern
  if (angleDiff <= beamwidth / 2) {
    // Main lobe (inside 3dB beamwidth)
    const attenuation = -3 * Math.pow(angleDiff / (beamwidth / 2), 2);
    return maxGain + attenuation;
  } else {
    // Side lobes (outside beamwidth)
    const sideLobeAttenuation = -12 - (angleDiff - beamwidth / 2) * 0.5;
    return maxGain + Math.max(sideLobeAttenuation, -40);  // Min -40 dB
  }
}

RSRP Calculation

function calculateRSRP(
  site: Site,
  sector: Sector,
  targetLat: number,
  targetLon: number
): number {
  // 1. Calculate distance
  const distance = haversineDistance(
    sector.lat, sector.lon, 
    targetLat, targetLon
  );
  
  // 2. Path loss
  const pathLoss = calculateOkumuraHata(
    distance / 1000,  // Convert to km
    sector.frequency,
    sector.height,
    1.5  // Assume 1.5m receiver height
  );
  
  // 3. Antenna gain (directional or omni)
  let antennaGain = sector.gain;
  if (sector.antennaType === 'directional') {
    const targetAzimuth = calculateBearing(
      sector.lat, sector.lon,
      targetLat, targetLon
    );
    antennaGain = calculateDirectionalGain(
      sector.azimuth,
      sector.beamwidth,
      sector.gain,
      targetAzimuth
    );
  }
  
  // 4. RSRP = Tx Power + Antenna Gain - Path Loss
  return sector.power + antennaGain - pathLoss;
}

Coverage Grid Generation

async function calculateCoverageGrid(
  sites: Site[],
  settings: CoverageSettings
): Promise<CoveragePoint[]> {
  // 1. Calculate bounding box
  const bounds = calculateBounds(sites, settings.radius);
  
  // 2. Generate grid points
  const gridPoints = generateGrid(bounds, settings.resolution);
  
  // 3. Use Web Workers for parallel calculation
  const workers = Array(4).fill(null).map(() => 
    new Worker('/workers/rf-worker.js')
  );
  
  // 4. Split work among workers
  const chunks = chunkArray(gridPoints, Math.ceil(gridPoints.length / workers.length));
  
  // 5. Calculate coverage for each chunk
  const results = await Promise.all(
    chunks.map((chunk, i) => 
      calculateChunk(workers[i], sites, chunk, settings)
    )
  );
  
  // 6. Combine results
  const coveragePoints = results.flat();
  
  // 7. Terminate workers
  workers.forEach(w => w.terminate());
  
  return coveragePoints;
}

🗺️ Geographic Rendering

Custom Canvas Heatmap

Problem: Standard Leaflet heatmap plugins scale with zoom level (bad!)

Solution: Custom geographic-scale canvas overlay using Leaflet's GridLayer

Architecture:

GeographicHeatmap Component
    ↓
HeatmapTileRenderer (singleton)
    ↓
Creates 256×256 canvas tiles
    ↓
Each tile = exact geographic coordinates
    ↓
Tile cache (Map<tileKey, canvas>)
    ↓
Rendered to Leaflet map

Implementation:

// HeatmapTileRenderer.ts
export class HeatmapTileRenderer {
  private tileCache = new Map<string, HTMLCanvasElement>();
  private radiusMeters: number;
  
  constructor(radiusMeters = 400) {
    this.radiusMeters = radiusMeters;
  }
  
  createTile(coords: L.Coords, coveragePoints: CoveragePoint[]): HTMLCanvasElement {
    const tileKey = `${coords.x}_${coords.y}_${coords.z}`;
    
    // Check cache
    if (this.tileCache.has(tileKey)) {
      return this.tileCache.get(tileKey)!;
    }
    
    // Create 256×256 canvas
    const canvas = document.createElement('canvas');
    canvas.width = 256;
    canvas.height = 256;
    const ctx = canvas.getContext('2d')!;
    
    // Calculate tile bounds (lat/lon)
    const tileBounds = this.getTileBounds(coords);
    
    // Filter points in this tile
    const relevantPoints = coveragePoints.filter(p =>
      p.lat >= tileBounds.south && p.lat <= tileBounds.north &&
      p.lon >= tileBounds.west && p.lon <= tileBounds.east
    );
    
    // Draw each point
    relevantPoints.forEach(point => {
      // Convert lat/lon to pixel coordinates within tile
      const pixelX = this.latLonToTilePixel(point.lon, tileBounds.west, tileBounds.east);
      const pixelY = this.latLonToTilePixel(point.lat, tileBounds.south, tileBounds.north);
      
      // Calculate radius in pixels (constant 400m geographic radius)
      const radiusPixels = this.metersToPixels(this.radiusMeters, point.lat, coords.z);
      
      // Draw circle
      ctx.fillStyle = this.rsrpToColor(point.rsrp);
      ctx.beginPath();
      ctx.arc(pixelX, pixelY, radiusPixels, 0, Math.PI * 2);
      ctx.fill();
    });
    
    // Cache tile
    this.tileCache.set(tileKey, canvas);
    
    return canvas;
  }
  
  metersToPixels(meters: number, latitude: number, zoom: number): number {
    // Earth circumference at equator: 40,075,017 meters
    // At latitude φ: circumference * cos(φ)
    const latCircumference = 40075017 * Math.cos(latitude * Math.PI / 180);
    
    // Pixels per meter at this zoom level
    const pixelsPerMeter = (256 * Math.pow(2, zoom)) / latCircumference;
    
    return meters * pixelsPerMeter;
  }
}

Key Insight: By calculating metersToPixels for each zoom level, we maintain true 400m radius regardless of zoom.

Coverage Boundary

Purpose: Show outline polygon of coverage area

// Calculate convex hull of coverage points
function calculateCoverageBoundary(points: CoveragePoint[]): [number, number][] {
  // 1. Filter to edge points
  const edgePoints = points.filter(isEdgePoint);
  
  // 2. Graham scan algorithm for convex hull
  const hull = grahamScan(edgePoints.map(p => [p.lat, p.lon]));
  
  // 3. Smooth polygon (optional)
  const smoothed = smoothPolygon(hull, smoothingFactor);
  
  return smoothed;
}

// Render as Leaflet Polygon
<Polygon
  positions={boundaryPoints}
  pathOptions={{
    color: '#ff6b35',
    weight: 3,
    opacity: 0.8,
    fill: false,
    dashArray: '10, 5'
  }}
/>

🔄 State Management

Zustand Stores

Philosophy: Separate stores for different concerns, minimal boilerplate

// src/store/sites.ts
export const useSitesStore = create<SitesStore>((set, get) => ({
  // State
  sites: [],
  selectedSiteId: null,
  
  // Actions
  addSite: (site) => set((state) => ({
    sites: [...state.sites, site]
  })),
  
  updateSite: (id, updates) => set((state) => ({
    sites: state.sites.map(s => s.id === id ? { ...s, ...updates } : s)
  })),
  
  deleteSite: (id) => {
    const site = get().sites.find(s => s.id === id);
    set((state) => ({
      sites: state.sites.filter(s => s.id !== id),
      selectedSiteId: state.selectedSiteId === id ? null : state.selectedSiteId
    }));
    
    // Show undo toast
    toast.success('Site deleted', {
      duration: 10000,
      action: {
        label: 'Undo',
        onClick: () => get().addSite(site!)
      }
    });
  },
  
  // Sector operations
  addSector: (siteId, sector) => set((state) => ({
    sites: state.sites.map(s =>
      s.id === siteId
        ? { ...s, sectors: [...s.sectors, sector] }
        : s
    )
  })),
  
  updateSector: (siteId, sectorId, updates) => set((state) => ({
    sites: state.sites.map(s =>
      s.id === siteId
        ? {
            ...s,
            sectors: s.sectors.map(sec =>
              sec.id === sectorId ? { ...sec, ...updates } : sec
            )
          }
        : s
    )
  }))
}));
// src/store/coverage.ts
export const useCoverageStore = create<CoverageStore>((set) => ({
  // State
  coveragePoints: [],
  isCalculating: false,
  lastCalculated: null,
  
  // Settings
  settings: {
    radius: 10000,
    resolution: 200,
    minSignal: -105,
    useTerrain: false,
    useBuildings: false
  },
  
  // Actions
  setCoveragePoints: (points) => set({
    coveragePoints: points,
    lastCalculated: new Date()
  }),
  
  updateSettings: (updates) => set((state) => ({
    settings: { ...state.settings, ...updates }
  })),
  
  clearCoverage: () => set({
    coveragePoints: [],
    lastCalculated: null
  })
}));

localStorage Persistence

// Auto-save to localStorage on changes
useSitesStore.subscribe((state) => {
  localStorage.setItem('rfcp-sites', JSON.stringify(state.sites));
});

// Load from localStorage on app start
useEffect(() => {
  const saved = localStorage.getItem('rfcp-sites');
  if (saved) {
    const sites = JSON.parse(saved);
    useSitesStore.getState().setSites(sites);
  }
}, []);

🚀 Deployment Architecture

Current Deployment

VPS A (Hetzner Germany)
├── IP: 2.56.207.143 (public), 10.10.10.1 (VPN)
├── OS: Ubuntu 24.04 LTS
├── Services:
│   ├── Caddy (reverse proxy, port 443)
│   ├── WireGuard (VPN server, port 51820)
│   ├── RFCP Frontend (static files)
│   ├── RFCP Backend (systemd service, port 8888)
│   ├── MongoDB (port 27017, VPN-only)
│   ├── Open5GS (LTE core)
│   ├── Matrix Synapse (communications)
│   └── Various other UMTC services

Caddy Configuration

# /etc/caddy/Caddyfile
{
    email oleg@eliah.one
    default_bind 10.10.10.1  # VPN-only
}

rfcp.eliah.one {
    bind 10.10.10.1
    
    # Frontend (static files)
    root * /opt/rfcp/frontend/dist
    file_server
    
    # SPA fallback (для React Router)
    try_files {path} /index.html
    
    # Headers
    header {
        X-Frame-Options "DENY"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "no-referrer"
    }
    
    # Logs
    log {
        output file /var/log/caddy/rfcp.log
        format json
    }
}

# Future: Backend API
# api.rfcp.eliah.one {
#     bind 10.10.10.1
#     reverse_proxy localhost:8888
# }

Backend Service

# /etc/systemd/system/rfcp-backend.service
[Unit]
Description=RFCP Backend API
After=network.target mongodb.service

[Service]
Type=simple
User=root
WorkingDirectory=/opt/rfcp/backend
ExecStart=/opt/rfcp/backend/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8888
Restart=always
RestartSec=10

# Environment
Environment="MONGODB_URL=mongodb://localhost:27017"
Environment="LOG_LEVEL=info"

[Install]
WantedBy=multi-user.target

Build & Deploy Process

# On development machine (через VPN):

# 1. Build frontend
cd /opt/rfcp/frontend
npm run build

# 2. Deploy to VPS (if remote)
# scp -r dist/* root@10.10.10.1:/opt/rfcp/frontend/dist/

# 3. Reload Caddy (if config changed)
ssh root@10.10.10.1 "systemctl reload caddy"

# 4. Check services
ssh root@10.10.10.1 "systemctl status rfcp-backend"
ssh root@10.10.10.1 "systemctl status caddy"

# 5. View logs
ssh root@10.10.10.1 "journalctl -u rfcp-backend -f"
ssh root@10.10.10.1 "journalctl -u caddy -f"

📚 Development History

Iteration Timeline

Phase 1: Foundation (Iterations 1-3)

  • Basic React setup + Leaflet integration
  • Simple site CRUD
  • Free Space path loss model
  • Initial heatmap (leaflet.heat plugin)

Phase 2: Multi-Site Support (Iterations 4-5)

  • Multiple sites on map
  • Site selection/editing UI
  • Okumura-Hata propagation model
  • Coverage aggregation (strongest signal wins)

Phase 3: Antenna Patterns (Iteration 6)

  • Omnidirectional vs Directional antennas
  • Azimuth and beamwidth controls
  • Gaussian antenna pattern
  • Directional gain calculation

Phase 4: Geographic Heatmap (Iterations 7-8) Major Milestone

  • Problem: Heatmap scaled with zoom (wrong!)
  • Solution: Custom canvas renderer with true geographic scale
  • 400m radius constant across all zoom levels
  • Tile-based rendering with cache
  • Replaced leaflet.heat dependency

Phase 5: UX Polish (Iterations 9-9.1)

  • NumberInput components with sliders
  • Delete confirmation dialogs
  • Undo toast (10s duration)
  • Keyboard shortcuts (N, Delete, Ctrl+Z, etc)
  • Dark mode improvements

Phase 6: Final Audit (Iteration 10)

  • TypeScript strict mode (0 errors)
  • ESLint cleanup (0 errors)
  • Code organization & dead code removal
  • Logger utility (dev/prod separation)
  • React.memo optimization
  • Bundle size optimization (163KB gzipped)

Phase 7: Bugfixes & Improvements (Iterations 10.1-10.6)

  • 10.1: Stack overflow fix (spread operator), Delete confirmation, Gradient colors
  • 10.2: Purple → Orange gradient (better aesthetics)
  • 10.3: Coverage boundary outline + threshold filters
  • 10.4: Stadia Maps 401 fix (mobile compatibility)
  • 10.5: Input validation + Site/Sector hierarchy UI
  • 10.6: Site modal dialogs + Batch operations

Key Learnings

1. Geographic Calculations are Complex

  • Web Mercator projection has distortions
  • Need precise lat/lon ↔ pixel conversions
  • Tile-based rendering is powerful but tricky
  • Constant geographic radius requires zoom-aware calculations

2. Spread Operator Has Limits

  • JavaScript argument limit: ~65k-125k
  • Never use Math.min(...largeArray)
  • Use reduce() or for...of loops instead

3. UX Matters for Professional Tools

  • Confirmation dialogs prevent accidents
  • Undo functionality builds confidence
  • Keyboard shortcuts increase productivity
  • Visual feedback (loading, errors, empty states) essential

4. TypeScript Strict Mode is Worth It

  • Catches bugs before runtime
  • Forces better code design
  • Minimal performance overhead
  • Essential for team projects

🎯 Future Roadmap

Phase 8: Backend MVP (2 months)

Week 1-2: Foundation

  • FastAPI setup + MongoDB
  • Basic CRUD endpoints
  • Project persistence
  • API documentation (Swagger)

Week 3-4: Terrain Integration Priority

  • SRTM elevation data service
  • Line-of-sight calculation
  • Fresnel zone clearance
  • Realistic path loss with terrain

Week 5-6: Building Obstacles

  • OpenStreetMap integration
  • Building polygon extraction
  • Urban area coverage reduction

Week 7-8: Frontend Integration

  • API client (TypeScript)
  • Replace localStorage with API
  • Terrain toggle UI
  • Coverage caching

Phase 9: Advanced Features (3-6 months)

Multi-User Support:

  • MAS OAuth integration
  • User authentication
  • Project ownership
  • Sharing & collaboration

Advanced RF:

  • Link budget calculator
  • Interference modeling
  • Multiple frequency bands
  • Custom propagation models

Visualization:

  • 3D terrain view
  • Signal strength profiles
  • Coverage comparison mode
  • Time-based analysis

Collaboration:

  • Real-time multi-user editing
  • Comments & annotations
  • Version history
  • Export to KML/GeoJSON

📝 Appendix

Build Commands

# Development
npm run dev              # Start dev server (http://localhost:5173)

# Build
npm run build            # Production build to dist/
npm run preview          # Preview production build

# Quality
npm run type-check       # TypeScript validation
npm run lint             # ESLint check
npm run lint:fix         # Auto-fix ESLint issues

Environment Variables

# .env (if needed)
VITE_API_BASE_URL=http://10.10.10.1:8888
VITE_ENABLE_TERRAIN=false

Performance Targets

  • Coverage calculation: <5s (typical: 10 sites, 200m resolution)
  • Tile rendering: <50ms per tile
  • Initial load: <2s on 3G
  • Bundle size: <500KB gzipped (achieved: 163KB)

Browser Support

  • Chrome/Edge: 120+
  • Firefox: 115+
  • Safari: 16+ (limited testing)
  • Mobile: Chrome/Safari

Document Status: COMPLETE
Last Updated: January 30, 2025
Maintained By: Олег
Next Review: After Backend MVP completion