39 KiB
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
- Project Overview
- System Architecture
- Frontend Architecture
- Backend Architecture
- Data Models
- RF Calculation Engine
- Geographic Rendering
- State Management
- Deployment Architecture
- Development History
- 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()orfor...ofloops 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