1255 lines
39 KiB
Markdown
1255 lines
39 KiB
Markdown
# 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](#project-overview)
|
||
2. [System Architecture](#system-architecture)
|
||
3. [Frontend Architecture](#frontend-architecture)
|
||
4. [Backend Architecture](#backend-architecture-planned)
|
||
5. [Data Models](#data-models)
|
||
6. [RF Calculation Engine](#rf-calculation-engine)
|
||
7. [Geographic Rendering](#geographic-rendering)
|
||
8. [State Management](#state-management)
|
||
9. [Deployment Architecture](#deployment-architecture)
|
||
10. [Development History](#development-history)
|
||
11. [Future Roadmap](#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)**
|
||
|
||
```typescript
|
||
// 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**
|
||
|
||
```typescript
|
||
// 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**
|
||
|
||
```typescript
|
||
// 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)
|
||
|
||
```python
|
||
# 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
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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)**
|
||
|
||
```typescript
|
||
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)
|
||
|
||
```typescript
|
||
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)
|
||
|
||
```python
|
||
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:**
|
||
|
||
```typescript
|
||
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
|
||
|
||
```typescript
|
||
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
|
||
|
||
```typescript
|
||
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:**
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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
|
||
)
|
||
}))
|
||
}));
|
||
```
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```caddyfile
|
||
# /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
|
||
|
||
```ini
|
||
# /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
|
||
|
||
```bash
|
||
# 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
|
||
|
||
```bash
|
||
# 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
|
||
|
||
```bash
|
||
# .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
|
||
|