# RFCP Iteration 1.5: Frontend ↔ Backend Integration **Date:** January 31, 2025 **Type:** Full-Stack Integration **Estimated:** 8-12 hours **Location:** `/opt/rfcp/frontend/` + `/opt/rfcp/backend/` --- ## 🎯 Goal Replace browser-based coverage calculation with backend API. Add propagation model selection UI and real-time progress indication. --- ## πŸ“Š Current State **Frontend:** - Coverage calculated in Web Workers (browser) - Settings stored in localStorage/IndexedDB - No connection to backend API - ~0.15s calculation time (simple model) **Backend:** - Full propagation engine ready (1.4) - 4 presets (fast/standard/detailed/full) - 6 propagation models available - API at `https://api.rfcp.eliah.one` **Gap:** - Frontend doesn't call backend - No model selection UI - No progress indication for slow calculations --- ## πŸ—οΈ Architecture ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Frontend β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ Site Panel │───▢│ Coverage Settings Panel β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”œβ”€β”€ Radius β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ Resolution β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ Preset [dropdown] β”‚ NEW β”‚ β”‚ β”‚ └── Model toggles β”‚ NEW β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ Coverage Service (API client) β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ POST /api/coverage/calculate β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ Progress polling / SSE β”‚ β”‚ β”‚ β”‚ └── Result caching β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ Map Visualization β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ Heatmap layer (existing) β”‚ β”‚ β”‚ β”‚ └── Model info overlay NEW β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ HTTPS β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Backend API β”‚ β”‚ POST /api/coverage/calculate β”‚ β”‚ GET /api/coverage/presets β”‚ β”‚ GET /api/terrain/elevation β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` --- ## βœ… Tasks ### Task 1.5.1: API Client Service (2-3 hours) **frontend/src/services/api.ts:** ```typescript const API_BASE = import.meta.env.VITE_API_URL || 'https://api.rfcp.eliah.one'; export interface CoverageRequest { sites: SiteParams[]; settings: CoverageSettings; } export interface SiteParams { lat: number; lon: number; height: number; power: number; gain: number; frequency: number; azimuth?: number; beamwidth?: number; } export interface CoverageSettings { radius: number; resolution: number; min_signal: number; preset?: 'fast' | 'standard' | 'detailed' | 'full'; use_terrain?: boolean; use_buildings?: boolean; use_materials?: boolean; use_dominant_path?: boolean; use_street_canyon?: boolean; use_reflections?: boolean; } export interface CoveragePoint { lat: number; lon: number; rsrp: number; distance: number; has_los: boolean; terrain_loss: number; building_loss: number; reflection_gain: number; } export interface CoverageResponse { points: CoveragePoint[]; count: number; settings: CoverageSettings; stats: CoverageStats; computation_time: number; models_used: string[]; } export interface CoverageStats { min_rsrp: number; max_rsrp: number; avg_rsrp: number; los_percentage: number; points_with_buildings: number; points_with_terrain_loss: number; points_with_reflection_gain: number; } export interface Preset { description: string; use_terrain: boolean; use_buildings: boolean; use_materials: boolean; use_dominant_path: boolean; use_street_canyon: boolean; use_reflections: boolean; estimated_speed: string; } class ApiService { private abortController: AbortController | null = null; async getPresets(): Promise> { const response = await fetch(`${API_BASE}/api/coverage/presets`); if (!response.ok) throw new Error('Failed to fetch presets'); const data = await response.json(); return data.presets; } async calculateCoverage( request: CoverageRequest, onProgress?: (progress: number) => void ): Promise { // Cancel previous request if running if (this.abortController) { this.abortController.abort(); } this.abortController = new AbortController(); const response = await fetch(`${API_BASE}/api/coverage/calculate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), signal: this.abortController.signal }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Coverage calculation failed'); } return response.json(); } cancelCalculation() { if (this.abortController) { this.abortController.abort(); this.abortController = null; } } async getElevation(lat: number, lon: number): Promise { const response = await fetch( `${API_BASE}/api/terrain/elevation?lat=${lat}&lon=${lon}` ); if (!response.ok) return 0; const data = await response.json(); return data.elevation; } } export const api = new ApiService(); ``` --- ### Task 1.5.2: Coverage Settings Panel Update (3-4 hours) **frontend/src/components/panels/CoverageSettingsPanel.tsx:** ```typescript import { useState, useEffect } from 'react'; import { api, Preset } from '../../services/api'; import { useCoverageStore } from '../../store/coverage'; import { NumberInput } from '../ui/NumberInput'; import { Toggle } from '../ui/Toggle'; export function CoverageSettingsPanel() { const { settings, updateSettings, isCalculating } = useCoverageStore(); const [presets, setPresets] = useState>({}); const [showAdvanced, setShowAdvanced] = useState(false); // Load presets on mount useEffect(() => { api.getPresets().then(setPresets).catch(console.error); }, []); const handlePresetChange = (preset: string) => { if (presets[preset]) { updateSettings({ preset, ...presets[preset] }); } }; return (

Coverage Settings

{/* Basic Settings */} updateSettings({ radius: v * 1000 })} min={1} max={100} step={1} unit="km" tooltip="Coverage calculation radius around each site" /> updateSettings({ resolution: v })} min={50} max={500} step={50} unit="m" tooltip="Grid spacing β€” lower = more accurate but slower" /> updateSettings({ min_signal: v })} min={-140} max={-50} step={5} unit="dBm" tooltip="RSRP threshold β€” points below this are hidden" /> {/* Propagation Model Preset */}
{presets[settings.preset || 'standard'] && (

{presets[settings.preset || 'standard'].description}

)}
{/* Advanced Toggles */}
{showAdvanced && (
updateSettings({ use_terrain: v })} disabled={isCalculating} /> updateSettings({ use_buildings: v })} disabled={isCalculating} /> updateSettings({ use_materials: v })} disabled={isCalculating || !settings.use_buildings} /> updateSettings({ use_dominant_path: v })} disabled={isCalculating} /> updateSettings({ use_street_canyon: v })} disabled={isCalculating} /> updateSettings({ use_reflections: v })} disabled={isCalculating} />
)}
); } ``` --- ### Task 1.5.3: Coverage Store Update (2-3 hours) **frontend/src/store/coverage.ts:** ```typescript import { create } from 'zustand'; import { api, CoverageResponse, CoverageSettings, CoveragePoint } from '../services/api'; import { useSitesStore } from './sites'; interface CoverageState { // Data points: CoveragePoint[]; stats: CoverageResponse['stats'] | null; // Settings settings: CoverageSettings; // UI State isCalculating: boolean; progress: number; error: string | null; lastCalculation: { time: number; models: string[]; } | null; // Actions updateSettings: (settings: Partial) => void; calculateCoverage: () => Promise; cancelCalculation: () => void; clearCoverage: () => void; } const DEFAULT_SETTINGS: CoverageSettings = { radius: 10000, resolution: 200, min_signal: -100, preset: 'standard', use_terrain: true, use_buildings: true, use_materials: true, use_dominant_path: false, use_street_canyon: false, use_reflections: false, }; export const useCoverageStore = create((set, get) => ({ points: [], stats: null, settings: DEFAULT_SETTINGS, isCalculating: false, progress: 0, error: null, lastCalculation: null, updateSettings: (newSettings) => { set((state) => ({ settings: { ...state.settings, ...newSettings } })); }, calculateCoverage: async () => { const { settings } = get(); const sites = useSitesStore.getState().sites; if (sites.length === 0) { set({ error: 'No sites to calculate coverage for' }); return; } set({ isCalculating: true, progress: 0, error: null }); try { // Convert sites to API format const apiSites = sites.flatMap(site => site.sectors.map(sector => ({ lat: site.lat, lon: site.lon, height: sector.height, power: 10 * Math.log10(sector.power * 1000), // W to dBm gain: sector.gain, frequency: sector.frequency, azimuth: sector.antennaType === 'directional' ? sector.azimuth : undefined, beamwidth: sector.antennaType === 'directional' ? sector.beamwidth : undefined, })) ); const response = await api.calculateCoverage({ sites: apiSites, settings }); set({ points: response.points, stats: response.stats, isCalculating: false, progress: 100, lastCalculation: { time: response.computation_time, models: response.models_used } }); } catch (error) { if (error instanceof Error && error.name === 'AbortError') { set({ isCalculating: false, progress: 0 }); } else { set({ isCalculating: false, error: error instanceof Error ? error.message : 'Calculation failed', progress: 0 }); } } }, cancelCalculation: () => { api.cancelCalculation(); set({ isCalculating: false, progress: 0 }); }, clearCoverage: () => { set({ points: [], stats: null, lastCalculation: null }); }, })); ``` --- ### Task 1.5.4: Calculate Button & Progress (2-3 hours) **frontend/src/components/CoverageButton.tsx:** ```typescript import { useCoverageStore } from '../store/coverage'; import { useSitesStore } from '../store/sites'; export function CoverageButton() { const { isCalculating, progress, calculateCoverage, cancelCalculation, lastCalculation } = useCoverageStore(); const sitesCount = useSitesStore((s) => s.sites.length); if (isCalculating) { return (
); } return (
{lastCalculation && ( {lastCalculation.time.toFixed(1)}s β€’ {lastCalculation.models.length} models )}
); } ``` **CSS (coverage-button.css):** ```css .coverage-button { display: flex; align-items: center; gap: 12px; } .calculate-btn { background: var(--primary); color: white; border: none; padding: 10px 20px; border-radius: 6px; font-weight: 600; cursor: pointer; transition: background 0.2s; } .calculate-btn:hover:not(:disabled) { background: var(--primary-dark); } .calculate-btn:disabled { opacity: 0.5; cursor: not-allowed; } .coverage-button.calculating { flex-direction: column; gap: 8px; } .progress-bar { width: 200px; height: 8px; background: var(--bg-secondary); border-radius: 4px; overflow: hidden; } .progress-fill { height: 100%; background: var(--primary); transition: width 0.3s; } .cancel-btn { background: var(--danger); color: white; border: none; padding: 6px 16px; border-radius: 4px; cursor: pointer; } .last-calc-info { font-size: 12px; color: var(--text-secondary); } ``` --- ### Task 1.5.5: Update Heatmap to Use API Points (2-3 hours) **frontend/src/components/map/CoverageLayer.tsx:** ```typescript import { useEffect, useMemo } from 'react'; import { useMap } from 'react-leaflet'; import { useCoverageStore } from '../../store/coverage'; import { createHeatmapTiles } from '../../lib/heatmap'; export function CoverageLayer() { const map = useMap(); const { points, settings } = useCoverageStore(); // Convert API points to heatmap format const heatmapData = useMemo(() => { if (!points.length) return []; return points.map(p => ({ lat: p.lat, lon: p.lon, value: p.rsrp, // Additional data for tooltips hasLos: p.has_los, terrainLoss: p.terrain_loss, buildingLoss: p.building_loss, reflectionGain: p.reflection_gain, })); }, [points]); useEffect(() => { if (!heatmapData.length) return; const tileLayer = createHeatmapTiles(heatmapData, { minSignal: settings.min_signal, maxSignal: -50, opacity: 0.7, }); tileLayer.addTo(map); return () => { map.removeLayer(tileLayer); }; }, [map, heatmapData, settings.min_signal]); return null; } ``` --- ### Task 1.5.6: Stats Panel Update (1-2 hours) **frontend/src/components/panels/StatsPanel.tsx:** ```typescript import { useCoverageStore } from '../../store/coverage'; export function StatsPanel() { const { stats, lastCalculation, points } = useCoverageStore(); if (!stats) { return (

Calculate coverage to see statistics

); } return (

Coverage Statistics

Points {points.length.toLocaleString()}
Min RSRP {stats.min_rsrp.toFixed(1)} dBm
Max RSRP {stats.max_rsrp.toFixed(1)} dBm
Avg RSRP {stats.avg_rsrp.toFixed(1)} dBm
Line of Sight {stats.los_percentage.toFixed(1)}%
Terrain Affected {stats.points_with_terrain_loss}
Building Affected {stats.points_with_buildings}
With Reflections {stats.points_with_reflection_gain}
{lastCalculation && (

Calculated in {lastCalculation.time.toFixed(1)}s

Models: {lastCalculation.models.join(', ')}

)}
); } ``` --- ### Task 1.5.7: Environment Configuration (1 hour) **frontend/.env.development:** ```env VITE_API_URL=https://api.rfcp.eliah.one ``` **frontend/.env.production:** ```env VITE_API_URL=https://api.rfcp.eliah.one ``` **frontend/.env.local (for local backend):** ```env VITE_API_URL=http://localhost:8888 ``` **frontend/vite.config.ts update:** ```typescript export default defineConfig({ // ... existing config define: { 'import.meta.env.VITE_API_URL': JSON.stringify(process.env.VITE_API_URL) } }); ``` --- ## πŸ§ͺ Testing ```bash # 1. Start backend (if not running) sudo systemctl start rfcp-backend # 2. Start frontend dev cd /opt/rfcp/frontend npm run dev # 3. Test in browser # - Create a site # - Select "Fast" preset # - Click Calculate Coverage # - Verify heatmap appears # - Check stats panel shows API data # 4. Test presets # - Switch to "Standard" - should take ~30s # - Switch to "Full" (small radius!) - should take ~2min # - Verify models_used changes # 5. Test cancel # - Start "Full" calculation # - Click Cancel # - Verify it stops ``` --- ## βœ… Success Criteria - [ ] Coverage calculated via backend API (not browser) - [ ] Preset dropdown shows 4 options with descriptions - [ ] Advanced toggles work independently - [ ] Progress indication during calculation - [ ] Cancel button stops calculation - [ ] Stats panel shows API response data - [ ] `computation_time` and `models_used` displayed - [ ] Heatmap renders API points correctly - [ ] Error handling for API failures - [ ] Works with multiple sites/sectors --- ## πŸ“ Files Changed ``` frontend/src/ β”œβ”€β”€ services/ β”‚ └── api.ts # NEW - API client β”œβ”€β”€ store/ β”‚ └── coverage.ts # MODIFIED - API integration β”œβ”€β”€ components/ β”‚ β”œβ”€β”€ panels/ β”‚ β”‚ β”œβ”€β”€ CoverageSettingsPanel.tsx # MODIFIED - preset UI β”‚ β”‚ └── StatsPanel.tsx # MODIFIED - API stats β”‚ β”œβ”€β”€ map/ β”‚ β”‚ └── CoverageLayer.tsx # MODIFIED - use API points β”‚ └── CoverageButton.tsx # NEW - calculate + progress β”œβ”€β”€ .env.development # NEW β”œβ”€β”€ .env.production # NEW └── vite.config.ts # MODIFIED ``` --- ## πŸ“ Notes - Remove or disable old Web Worker calculation after integration confirmed - Keep localStorage for sites/settings backup (offline fallback) - Consider WebSocket for real progress updates (future enhancement) - API timeout should be generous (5+ minutes for "full" preset) --- ## πŸ”œ Next After 1.5 complete: - **1.4.1** β€” Enhanced Environment (R-tree, water, vegetation) - **1.4.2** β€” Extra Factors (weather, indoor) - **2.1** β€” Desktop Installer --- **Ready for Claude Code** πŸš€