From 3c92fdbb9099582eba93a399318c8c4090ba90bb Mon Sep 17 00:00:00 2001 From: mytec Date: Sat, 31 Jan 2026 01:34:51 +0200 Subject: [PATCH] @mytec: iter1.5 ready for testing --- frontend/.env.development | 1 + frontend/.env.production | 1 + frontend/src/App.tsx | 251 ++++++++++++------ .../src/components/map/CoverageBoundary.tsx | 7 +- .../src/components/map/HeatmapTileRenderer.ts | 2 +- .../src/components/panels/CoverageStats.tsx | 132 +++++++-- frontend/src/services/api.ts | 133 ++++++++++ frontend/src/store/coverage.ts | 114 +++++++- frontend/src/types/coverage.ts | 31 ++- frontend/src/types/index.ts | 1 + 10 files changed, 564 insertions(+), 109 deletions(-) create mode 100644 frontend/.env.development create mode 100644 frontend/.env.production create mode 100644 frontend/src/services/api.ts diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..fe210ad --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1 @@ +VITE_API_URL=https://api.rfcp.eliah.one diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..fe210ad --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1 @@ +VITE_API_URL=https://api.rfcp.eliah.one diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dba0faf..0673297 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,11 @@ import { useEffect, useState, useCallback } from 'react'; import type { Site } from '@/types/index.ts'; +import type { Preset } from '@/services/api.ts'; +import { api } from '@/services/api.ts'; import { useSitesStore } from '@/store/sites.ts'; import { useCoverageStore } from '@/store/coverage.ts'; import { useSettingsStore } from '@/store/settings.ts'; import { useHistoryStore, pushToFuture, pushToPast } from '@/store/history.ts'; -import { RFCalculator } from '@/rf/calculator.ts'; import { useToastStore } from '@/components/ui/Toast.tsx'; import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts.ts'; import { useUnsavedChanges } from '@/hooks/useUnsavedChanges.ts'; @@ -27,8 +28,6 @@ import Button from '@/components/ui/Button.tsx'; import NumberInput from '@/components/ui/NumberInput.tsx'; import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx'; -const calculator = new RFCalculator(); - /** * Restore a sites snapshot: replace all sites in IndexedDB + Zustand. * Used by undo/redo. @@ -57,9 +56,21 @@ export default function App() { const coverageResult = useCoverageStore((s) => s.result); const isCalculating = useCoverageStore((s) => s.isCalculating); const settings = useCoverageStore((s) => s.settings); - const setResult = useCoverageStore((s) => s.setResult); - const setIsCalculating = useCoverageStore((s) => s.setIsCalculating); const heatmapVisible = useCoverageStore((s) => s.heatmapVisible); + const coverageError = useCoverageStore((s) => s.error); + const calculateCoverageApi = useCoverageStore((s) => s.calculateCoverage); + const cancelCalculation = useCoverageStore((s) => s.cancelCalculation); + + // Propagation presets from API + const [presets, setPresets] = useState>({}); + const [showAdvanced, setShowAdvanced] = useState(false); + + // Load presets on mount + useEffect(() => { + api.getPresets().then(setPresets).catch((err) => { + logger.warn('Failed to load presets:', err); + }); + }, []); const addToast = useToastStore((s) => s.addToast); @@ -275,7 +286,7 @@ export default function App() { addToast('Redo', 'info'); }, [addToast]); - // Calculate coverage (with better error handling) + // Calculate coverage via backend API const handleCalculate = useCallback(async () => { const currentSites = useSitesStore.getState().sites; if (currentSites.length === 0) { @@ -295,74 +306,46 @@ export default function App() { return; } - // Warn if grid will be auto-coarsened (very large area + fine resolution) - const latitudes = currentSites.map((s) => s.lat); - const longitudes = currentSites.map((s) => s.lon); - const latRange = (Math.max(...latitudes) - Math.min(...latitudes)) + (2 * currentSettings.radius / 111); - const lonRange = (Math.max(...longitudes) - Math.min(...longitudes)) + (2 * currentSettings.radius / 111); - const estPoints = Math.ceil(latRange * 111000 / currentSettings.resolution) * - Math.ceil(lonRange * 111000 / currentSettings.resolution); - if (estPoints > 500_000) { - addToast( - `Large area detected (~${(estPoints / 1_000_000).toFixed(1)}M points). Resolution will be auto-adjusted for performance.`, - 'warning' - ); - } - - setIsCalculating(true); try { - const latitudes = currentSites.map((s) => s.lat); - const longitudes = currentSites.map((s) => s.lon); - const radiusDeg = currentSettings.radius / 111; - const avgLat = - (Math.max(...latitudes) + Math.min(...latitudes)) / 2; - const lonRadiusDeg = - radiusDeg / Math.cos((avgLat * Math.PI) / 180); + await calculateCoverageApi(); - const bounds = { - north: Math.max(...latitudes) + radiusDeg, - south: Math.min(...latitudes) - radiusDeg, - east: Math.max(...longitudes) + lonRadiusDeg, - west: Math.min(...longitudes) - lonRadiusDeg, - }; + // Check result after calculation + const result = useCoverageStore.getState().result; + const error = useCoverageStore.getState().error; - const result = await calculator.calculateCoverage( - currentSites, - bounds, - currentSettings - ); - - setResult(result); - - if (result.points.length === 0) { - addToast( - 'No coverage points found. Try increasing radius or lowering threshold.', - 'warning' - ); - } else { - addToast( - `Calculated ${result.totalPoints.toLocaleString()} points in ${(result.calculationTime / 1000).toFixed(1)}s`, - 'success' - ); + if (error) { + let userMessage = 'Calculation failed'; + if (error.includes('timeout') || error.includes('Timeout')) { + userMessage = 'Calculation timeout. Try reducing radius or increasing resolution.'; + } else if (error.includes('fetch') || error.includes('network') || error.includes('Failed')) { + userMessage = `API error: ${error}. Check your connection.`; + } else { + userMessage = `Calculation failed: ${error}`; + } + addToast(userMessage, 'error'); + } else if (result) { + if (result.points.length === 0) { + addToast( + 'No coverage points found. Try increasing radius or lowering threshold.', + 'warning' + ); + } else { + const timeStr = result.calculationTime.toFixed(1); + const modelsStr = result.modelsUsed?.length + ? ` • ${result.modelsUsed.length} models` + : ''; + addToast( + `Calculated ${result.totalPoints.toLocaleString()} points in ${timeStr}s${modelsStr}`, + 'success' + ); + } } } catch (err) { logger.error('Coverage calculation error:', err); const msg = err instanceof Error ? err.message : 'Unknown error'; - - let userMessage = 'Calculation failed'; - if (msg.includes('timeout')) { - userMessage = 'Calculation timeout. Try reducing radius or increasing resolution.'; - } else if (msg.includes('worker') || msg.includes('Worker')) { - userMessage = 'Web Worker error. Please refresh the page.'; - } else { - userMessage = `Calculation failed: ${msg}`; - } - - addToast(userMessage, 'error'); - } finally { - setIsCalculating(false); + addToast(`Calculation failed: ${msg}`, 'error'); } - }, [setIsCalculating, setResult, addToast]); + }, [calculateCoverageApi, addToast]); // Save site from modal and trigger calculation const handleModalSaveAndCalculate = useCallback(async (data: SiteFormValues) => { @@ -424,21 +407,27 @@ export default function App() { > ? - + + ) : ( + + )} + {showAdvanced && ( +
+ {[ + { key: 'use_terrain' as const, label: 'Terrain (SRTM)', disabled: false }, + { key: 'use_buildings' as const, label: 'Buildings (OSM)', disabled: false }, + { key: 'use_materials' as const, label: 'Building Materials', disabled: !settings.use_buildings }, + { key: 'use_dominant_path' as const, label: 'Dominant Path', disabled: false }, + { key: 'use_street_canyon' as const, label: 'Street Canyon', disabled: false }, + { key: 'use_reflections' as const, label: 'Reflections', disabled: false }, + ].map(({ key, label, disabled }) => ( + + ))} +
+ )} + + + {/* Coverage error */} + {coverageError && ( +
+

+ {coverageError} +

+
+ )} + {/* Coverage statistics */} {/* Export coverage data */} diff --git a/frontend/src/components/map/CoverageBoundary.tsx b/frontend/src/components/map/CoverageBoundary.tsx index 9f8a332..27e75c4 100644 --- a/frontend/src/components/map/CoverageBoundary.tsx +++ b/frontend/src/components/map/CoverageBoundary.tsx @@ -37,13 +37,14 @@ export default function CoverageBoundary({ const boundaryPaths = useMemo(() => { if (!visible || points.length === 0) return []; - // Group points by siteId + // Group points by siteId (fallback to 'all' when siteId not available from API) const bySite = new Map(); 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';