();
for (const p of points) {
- let arr = bySite.get(p.siteId);
+ const key = p.siteId || 'all';
+ let arr = bySite.get(key);
if (!arr) {
arr = [];
- bySite.set(p.siteId, arr);
+ bySite.set(key, arr);
}
arr.push(p);
}
diff --git a/frontend/src/components/map/HeatmapTileRenderer.ts b/frontend/src/components/map/HeatmapTileRenderer.ts
index f3453d2..488785b 100644
--- a/frontend/src/components/map/HeatmapTileRenderer.ts
+++ b/frontend/src/components/map/HeatmapTileRenderer.ts
@@ -28,7 +28,7 @@ export interface HeatmapPoint {
lat: number;
lon: number;
rsrp: number;
- siteId: string;
+ siteId?: string;
}
export class HeatmapTileRenderer {
diff --git a/frontend/src/components/panels/CoverageStats.tsx b/frontend/src/components/panels/CoverageStats.tsx
index 7513dbc..2f8744a 100644
--- a/frontend/src/components/panels/CoverageStats.tsx
+++ b/frontend/src/components/panels/CoverageStats.tsx
@@ -1,9 +1,12 @@
import { memo } from 'react';
-import type { CoveragePoint } from '@/types/index.ts';
+import type { CoveragePoint, CoverageApiStats } from '@/types/index.ts';
interface CoverageStatsProps {
points: CoveragePoint[];
resolution: number; // meters
+ stats?: CoverageApiStats;
+ calculationTime?: number; // seconds
+ modelsUsed?: string[];
}
/**
@@ -33,7 +36,7 @@ function classifyPoints(points: CoveragePoint[]) {
return counts;
}
-export default memo(function CoverageStats({ points, resolution }: CoverageStatsProps) {
+export default memo(function CoverageStats({ points, resolution, stats, calculationTime, modelsUsed }: CoverageStatsProps) {
if (points.length === 0) {
return (
@@ -57,19 +60,29 @@ export default memo(function CoverageStats({ points, resolution }: CoverageStats
const totalArea = estimateAreaKm2(points.length, resolution);
const total = points.length;
- // Use reduce instead of Math.min/max spread — spread crashes on 65k+ elements
- let minRSRP = Infinity;
- let maxRSRP = -Infinity;
- let sumRSRP = 0;
- for (const p of points) {
- if (p.rsrp < minRSRP) minRSRP = p.rsrp;
- if (p.rsrp > maxRSRP) maxRSRP = p.rsrp;
- sumRSRP += p.rsrp;
- }
- const avgRSRP = sumRSRP / total;
+ // Use API stats if available, otherwise compute from points
+ let minRSRP: number;
+ let maxRSRP: number;
+ let avgRSRP: number;
- // Unique sites contributing to coverage
- const uniqueSites = new Set(points.map((p) => p.siteId)).size;
+ if (stats) {
+ minRSRP = stats.min_rsrp;
+ maxRSRP = stats.max_rsrp;
+ avgRSRP = stats.avg_rsrp;
+ } else {
+ minRSRP = Infinity;
+ maxRSRP = -Infinity;
+ let sumRSRP = 0;
+ for (const p of points) {
+ if (p.rsrp < minRSRP) minRSRP = p.rsrp;
+ if (p.rsrp > maxRSRP) maxRSRP = p.rsrp;
+ sumRSRP += p.rsrp;
+ }
+ avgRSRP = sumRSRP / total;
+ }
+
+ // Unique sites contributing to coverage (from siteId if present)
+ const uniqueSites = new Set(points.map((p) => p.siteId).filter(Boolean)).size;
const levels = [
{ ...LEVELS[0], count: counts.excellent },
@@ -104,18 +117,93 @@ export default memo(function CoverageStats({ points, resolution }: CoverageStats
{avgRSRP.toFixed(1)} dBm
-
-
Sites
-
- {uniqueSites}
+ {uniqueSites > 0 ? (
+
+
Sites
+
+ {uniqueSites}
+
-
+ ) : (
+
+
Range
+
+ {minRSRP.toFixed(0)} / {maxRSRP.toFixed(0)} dBm
+
+
+ )}
+ {/* API propagation stats */}
+ {stats && (
+
+
+ Propagation Details
+
+
+
+ Line of Sight
+
+ {stats.los_percentage.toFixed(1)}%
+
+
+ {stats.points_with_terrain_loss > 0 && (
+
+ Terrain Loss
+
+ {stats.points_with_terrain_loss}
+
+
+ )}
+ {stats.points_with_buildings > 0 && (
+
+ Building Loss
+
+ {stats.points_with_buildings}
+
+
+ )}
+ {stats.points_with_reflection_gain > 0 && (
+
+ Reflections
+
+ {stats.points_with_reflection_gain}
+
+
+ )}
+
+
+ )}
+
+ {/* Calculation info */}
+ {(calculationTime !== undefined || modelsUsed) && (
+
+ {calculationTime !== undefined && (
+
+ Computed in {calculationTime.toFixed(1)}s
+
+ )}
+ {modelsUsed && modelsUsed.length > 0 && (
+
+ {modelsUsed.map((model) => (
+
+ {model}
+
+ ))}
+
+ )}
+
+ )}
+
{/* RSRP range */}
-
- Range: {minRSRP.toFixed(1)} to {maxRSRP.toFixed(1)} dBm
-
+ {!stats && (
+
+ Range: {minRSRP.toFixed(1)} to {maxRSRP.toFixed(1)} dBm
+
+ )}
{/* Signal quality breakdown */}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
new file mode 100644
index 0000000..f67c12d
--- /dev/null
+++ b/frontend/src/services/api.ts
@@ -0,0 +1,133 @@
+/**
+ * Backend API client for RFCP coverage calculation
+ */
+
+const API_BASE = import.meta.env.VITE_API_URL || 'https://api.rfcp.eliah.one';
+
+// === Request types ===
+
+export interface ApiSiteParams {
+ lat: number;
+ lon: number;
+ height: number;
+ power: number; // dBm
+ gain: number; // dBi
+ frequency: number; // MHz
+ azimuth?: number;
+ beamwidth?: number;
+}
+
+export interface ApiCoverageSettings {
+ radius: number; // meters
+ resolution: number; // meters
+ min_signal: number; // dBm
+ 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 CoverageRequest {
+ sites: ApiSiteParams[];
+ settings: ApiCoverageSettings;
+}
+
+// === Response types ===
+
+export interface ApiCoveragePoint {
+ lat: number;
+ lon: number;
+ rsrp: number;
+ distance: number;
+ has_los: boolean;
+ terrain_loss: number;
+ building_loss: number;
+ reflection_gain: number;
+}
+
+export interface ApiCoverageStats {
+ 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 CoverageResponse {
+ points: ApiCoveragePoint[];
+ count: number;
+ settings: ApiCoverageSettings;
+ stats: ApiCoverageStats;
+ computation_time: number;
+ models_used: string[];
+}
+
+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;
+}
+
+// === API Client ===
+
+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): 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().catch(() => ({ detail: 'Coverage calculation failed' }));
+ throw new Error(error.detail || 'Coverage calculation failed');
+ }
+
+ this.abortController = null;
+ 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();
diff --git a/frontend/src/store/coverage.ts b/frontend/src/store/coverage.ts
index 0f52851..2b6a2c5 100644
--- a/frontend/src/store/coverage.ts
+++ b/frontend/src/store/coverage.ts
@@ -1,11 +1,15 @@
import { create } from 'zustand';
-import type { CoverageResult, CoverageSettings } from '@/types/index.ts';
+import { api } from '@/services/api.ts';
+import { useSitesStore } from '@/store/sites.ts';
+import type { CoverageResult, CoverageSettings, CoverageApiStats } from '@/types/index.ts';
+import type { ApiSiteParams } from '@/services/api.ts';
interface CoverageState {
result: CoverageResult | null;
isCalculating: boolean;
settings: CoverageSettings;
heatmapVisible: boolean;
+ error: string | null;
setResult: (result: CoverageResult | null) => void;
clearCoverage: () => void;
@@ -13,9 +17,14 @@ interface CoverageState {
updateSettings: (settings: Partial) => void;
toggleHeatmap: () => void;
setHeatmapVisible: (val: boolean) => void;
+ setError: (error: string | null) => void;
+
+ // API-driven calculation
+ calculateCoverage: () => Promise;
+ cancelCalculation: () => void;
}
-export const useCoverageStore = create((set) => ({
+export const useCoverageStore = create((set, get) => ({
result: null,
isCalculating: false,
settings: {
@@ -24,11 +33,20 @@ export const useCoverageStore = create((set) => ({
rsrpThreshold: -100,
heatmapOpacity: 0.7,
heatmapRadius: 400,
+ // Propagation model defaults (standard preset)
+ preset: 'standard',
+ use_terrain: true,
+ use_buildings: true,
+ use_materials: true,
+ use_dominant_path: false,
+ use_street_canyon: false,
+ use_reflections: false,
},
heatmapVisible: true,
+ error: null,
setResult: (result) => set({ result }),
- clearCoverage: () => set({ result: null }),
+ clearCoverage: () => set({ result: null, error: null }),
setIsCalculating: (val) => set({ isCalculating: val }),
updateSettings: (newSettings) =>
set((state) => ({
@@ -36,4 +54,94 @@ export const useCoverageStore = create((set) => ({
})),
toggleHeatmap: () => set((s) => ({ heatmapVisible: !s.heatmapVisible })),
setHeatmapVisible: (val) => set({ heatmapVisible: val }),
+ setError: (error) => set({ error }),
+
+ 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, error: null });
+
+ try {
+ // Convert sites to API format
+ // Each site is treated as a separate sector (flat model)
+ const apiSites: ApiSiteParams[] = sites
+ .filter((s) => s.visible)
+ .map((site) => ({
+ lat: site.lat,
+ lon: site.lon,
+ height: site.height,
+ power: site.power, // Already in dBm
+ gain: site.gain,
+ frequency: site.frequency,
+ azimuth: site.antennaType === 'sector' ? site.azimuth : undefined,
+ beamwidth: site.antennaType === 'sector' ? site.beamwidth : undefined,
+ }));
+
+ if (apiSites.length === 0) {
+ set({ isCalculating: false, error: 'No visible sites to calculate' });
+ return;
+ }
+
+ const response = await api.calculateCoverage({
+ sites: apiSites,
+ settings: {
+ radius: settings.radius * 1000, // km → meters
+ resolution: settings.resolution,
+ min_signal: settings.rsrpThreshold,
+ preset: settings.preset,
+ use_terrain: settings.use_terrain,
+ use_buildings: settings.use_buildings,
+ use_materials: settings.use_materials,
+ use_dominant_path: settings.use_dominant_path,
+ use_street_canyon: settings.use_street_canyon,
+ use_reflections: settings.use_reflections,
+ },
+ });
+
+ // Map API response to CoverageResult for existing heatmap/boundary components
+ const result: CoverageResult = {
+ points: response.points.map((p) => ({
+ lat: p.lat,
+ lon: p.lon,
+ rsrp: p.rsrp,
+ distance: p.distance,
+ has_los: p.has_los,
+ terrain_loss: p.terrain_loss,
+ building_loss: p.building_loss,
+ reflection_gain: p.reflection_gain,
+ })),
+ calculationTime: response.computation_time,
+ totalPoints: response.count,
+ settings: settings,
+ stats: response.stats as CoverageApiStats,
+ modelsUsed: response.models_used,
+ };
+
+ set({
+ result,
+ isCalculating: false,
+ error: null,
+ });
+ } catch (err) {
+ if (err instanceof Error && err.name === 'AbortError') {
+ set({ isCalculating: false });
+ } else {
+ set({
+ isCalculating: false,
+ error: err instanceof Error ? err.message : 'Coverage calculation failed',
+ });
+ }
+ }
+ },
+
+ cancelCalculation: () => {
+ api.cancelCalculation();
+ set({ isCalculating: false });
+ },
}));
diff --git a/frontend/src/types/coverage.ts b/frontend/src/types/coverage.ts
index 2647542..0d8ef77 100644
--- a/frontend/src/types/coverage.ts
+++ b/frontend/src/types/coverage.ts
@@ -2,14 +2,33 @@ export interface CoveragePoint {
lat: number;
lon: number;
rsrp: number; // dBm (calculated signal strength)
- siteId: string; // which site provides this coverage
+ siteId?: string; // which site provides this coverage (browser calc only)
+ // API-provided fields
+ distance?: number; // meters from site
+ has_los?: boolean; // line-of-sight to transmitter
+ terrain_loss?: number; // dB terrain obstruction loss
+ building_loss?: number; // dB building penetration loss
+ reflection_gain?: number; // dB reflection signal gain
}
export interface CoverageResult {
points: CoveragePoint[];
- calculationTime: number; // milliseconds
+ calculationTime: number; // seconds (was ms for browser calc)
totalPoints: number;
settings: CoverageSettings;
+ // API-provided fields
+ stats?: CoverageApiStats;
+ modelsUsed?: string[];
+}
+
+export interface CoverageApiStats {
+ 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 CoverageSettings {
@@ -18,6 +37,14 @@ export interface CoverageSettings {
rsrpThreshold: number; // dBm (minimum signal to display)
heatmapOpacity: number; // 0.3-1.0 (heatmap layer opacity)
heatmapRadius: number; // meters (coverage point visual radius, 200/400/600)
+ // Propagation model settings (backend API)
+ 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 GridPoint {
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 746c15c..30c5aab 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -3,6 +3,7 @@ export type {
CoveragePoint,
CoverageResult,
CoverageSettings,
+ CoverageApiStats,
GridPoint,
} from './coverage.ts';
export type { FrequencyBand } from './frequency.ts';