Files
rfcp/docs/devlog/back/RFCP-Iteration-1.5-Frontend-Backend-Integration.md

23 KiB

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:

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<Record<string, Preset>> {
    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<CoverageResponse> {
    // 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<number> {
    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:

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<Record<string, Preset>>({});
  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 (
    <div className="coverage-settings-panel">
      <h3>Coverage Settings</h3>

      {/* Basic Settings */}
      <NumberInput
        label="Radius"
        value={settings.radius / 1000}
        onChange={(v) => updateSettings({ radius: v * 1000 })}
        min={1}
        max={100}
        step={1}
        unit="km"
        tooltip="Coverage calculation radius around each site"
      />

      <NumberInput
        label="Resolution"
        value={settings.resolution}
        onChange={(v) => updateSettings({ resolution: v })}
        min={50}
        max={500}
        step={50}
        unit="m"
        tooltip="Grid spacing — lower = more accurate but slower"
      />

      <NumberInput
        label="Min Signal"
        value={settings.min_signal}
        onChange={(v) => updateSettings({ min_signal: v })}
        min={-140}
        max={-50}
        step={5}
        unit="dBm"
        tooltip="RSRP threshold — points below this are hidden"
      />

      {/* Propagation Model Preset */}
      <div className="setting-group">
        <label>Propagation Model</label>
        <select 
          value={settings.preset || 'standard'}
          onChange={(e) => handlePresetChange(e.target.value)}
          disabled={isCalculating}
        >
          {Object.entries(presets).map(([key, preset]) => (
            <option key={key} value={key}>
              {key.charAt(0).toUpperCase() + key.slice(1)}  {preset.estimated_speed}
            </option>
          ))}
        </select>
        {presets[settings.preset || 'standard'] && (
          <p className="hint">
            {presets[settings.preset || 'standard'].description}
          </p>
        )}
      </div>

      {/* Advanced Toggles */}
      <div className="advanced-section">
        <button 
          className="advanced-toggle"
          onClick={() => setShowAdvanced(!showAdvanced)}
        >
          {showAdvanced ? '▼' : '▶'} Advanced Options
        </button>

        {showAdvanced && (
          <div className="advanced-options">
            <Toggle
              label="Terrain (SRTM)"
              checked={settings.use_terrain}
              onChange={(v) => updateSettings({ use_terrain: v })}
              disabled={isCalculating}
            />
            <Toggle
              label="Buildings (OSM)"
              checked={settings.use_buildings}
              onChange={(v) => updateSettings({ use_buildings: v })}
              disabled={isCalculating}
            />
            <Toggle
              label="Building Materials"
              checked={settings.use_materials}
              onChange={(v) => updateSettings({ use_materials: v })}
              disabled={isCalculating || !settings.use_buildings}
            />
            <Toggle
              label="Dominant Path Analysis"
              checked={settings.use_dominant_path}
              onChange={(v) => updateSettings({ use_dominant_path: v })}
              disabled={isCalculating}
            />
            <Toggle
              label="Street Canyon"
              checked={settings.use_street_canyon}
              onChange={(v) => updateSettings({ use_street_canyon: v })}
              disabled={isCalculating}
            />
            <Toggle
              label="Reflections"
              checked={settings.use_reflections}
              onChange={(v) => updateSettings({ use_reflections: v })}
              disabled={isCalculating}
            />
          </div>
        )}
      </div>
    </div>
  );
}

Task 1.5.3: Coverage Store Update (2-3 hours)

frontend/src/store/coverage.ts:

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<CoverageSettings>) => void;
  calculateCoverage: () => Promise<void>;
  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<CoverageState>((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:

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 (
      <div className="coverage-button calculating">
        <div className="progress-bar">
          <div 
            className="progress-fill" 
            style={{ width: `${progress}%` }}
          />
        </div>
        <button onClick={cancelCalculation} className="cancel-btn">
          Cancel
        </button>
      </div>
    );
  }

  return (
    <div className="coverage-button">
      <button 
        onClick={calculateCoverage}
        disabled={sitesCount === 0}
        className="calculate-btn"
      >
        Calculate Coverage
      </button>
      {lastCalculation && (
        <span className="last-calc-info">
          {lastCalculation.time.toFixed(1)}s  {lastCalculation.models.length} models
        </span>
      )}
    </div>
  );
}

CSS (coverage-button.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:

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:

import { useCoverageStore } from '../../store/coverage';

export function StatsPanel() {
  const { stats, lastCalculation, points } = useCoverageStore();

  if (!stats) {
    return (
      <div className="stats-panel empty">
        <p>Calculate coverage to see statistics</p>
      </div>
    );
  }

  return (
    <div className="stats-panel">
      <h3>Coverage Statistics</h3>

      <div className="stats-grid">
        <div className="stat">
          <span className="label">Points</span>
          <span className="value">{points.length.toLocaleString()}</span>
        </div>

        <div className="stat">
          <span className="label">Min RSRP</span>
          <span className="value">{stats.min_rsrp.toFixed(1)} dBm</span>
        </div>

        <div className="stat">
          <span className="label">Max RSRP</span>
          <span className="value">{stats.max_rsrp.toFixed(1)} dBm</span>
        </div>

        <div className="stat">
          <span className="label">Avg RSRP</span>
          <span className="value">{stats.avg_rsrp.toFixed(1)} dBm</span>
        </div>

        <div className="stat">
          <span className="label">Line of Sight</span>
          <span className="value">{stats.los_percentage.toFixed(1)}%</span>
        </div>

        <div className="stat">
          <span className="label">Terrain Affected</span>
          <span className="value">{stats.points_with_terrain_loss}</span>
        </div>

        <div className="stat">
          <span className="label">Building Affected</span>
          <span className="value">{stats.points_with_buildings}</span>
        </div>

        <div className="stat">
          <span className="label">With Reflections</span>
          <span className="value">{stats.points_with_reflection_gain}</span>
        </div>
      </div>

      {lastCalculation && (
        <div className="calc-info">
          <p>
            Calculated in <strong>{lastCalculation.time.toFixed(1)}s</strong>
          </p>
          <p className="models">
            Models: {lastCalculation.models.join(', ')}
          </p>
        </div>
      )}
    </div>
  );
}

Task 1.5.7: Environment Configuration (1 hour)

frontend/.env.development:

VITE_API_URL=https://api.rfcp.eliah.one

frontend/.env.production:

VITE_API_URL=https://api.rfcp.eliah.one

frontend/.env.local (for local backend):

VITE_API_URL=http://localhost:8888

frontend/vite.config.ts update:

export default defineConfig({
  // ... existing config
  define: {
    'import.meta.env.VITE_API_URL': JSON.stringify(process.env.VITE_API_URL)
  }
});

🧪 Testing

# 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 🚀