@mytec: iter1.5 ready for testing
This commit is contained in:
1
frontend/.env.development
Normal file
1
frontend/.env.development
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=https://api.rfcp.eliah.one
|
||||
1
frontend/.env.production
Normal file
1
frontend/.env.production
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=https://api.rfcp.eliah.one
|
||||
@@ -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<Record<string, Preset>>({});
|
||||
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() {
|
||||
>
|
||||
?
|
||||
</button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isCalculating ? 'secondary' : 'primary'}
|
||||
onClick={handleCalculate}
|
||||
disabled={isCalculating || sites.length === 0}
|
||||
>
|
||||
{isCalculating ? (
|
||||
{isCalculating ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={cancelCalculation}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="animate-spin inline-block w-3 h-3 border-2 border-white border-t-transparent rounded-full" />
|
||||
Calculating...
|
||||
Cancel
|
||||
</span>
|
||||
) : (
|
||||
'Calculate Coverage'
|
||||
)}
|
||||
</Button>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={handleCalculate}
|
||||
disabled={sites.length === 0}
|
||||
>
|
||||
Calculate Coverage
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setPanelCollapsed(!panelCollapsed)}
|
||||
className="text-slate-400 hover:text-white text-sm sm:hidden min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
@@ -685,6 +674,100 @@ export default function App() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Propagation Model Preset */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-dark-text">
|
||||
Propagation Model
|
||||
</label>
|
||||
<select
|
||||
value={settings.preset || 'standard'}
|
||||
onChange={(e) => {
|
||||
const key = e.target.value;
|
||||
const preset = presets[key];
|
||||
if (preset) {
|
||||
useCoverageStore.getState().updateSettings({
|
||||
preset: key as 'fast' | 'standard' | 'detailed' | 'full',
|
||||
use_terrain: preset.use_terrain,
|
||||
use_buildings: preset.use_buildings,
|
||||
use_materials: preset.use_materials,
|
||||
use_dominant_path: preset.use_dominant_path,
|
||||
use_street_canyon: preset.use_street_canyon,
|
||||
use_reflections: preset.use_reflections,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={isCalculating}
|
||||
className="w-full mt-1 px-2 py-1.5 text-sm bg-white dark:bg-dark-border border border-gray-300 dark:border-dark-border rounded-md text-gray-700 dark:text-dark-text disabled:opacity-50"
|
||||
>
|
||||
{Object.keys(presets).length > 0 ? (
|
||||
Object.entries(presets).map(([key, preset]) => (
|
||||
<option key={key} value={key}>
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)} — {preset.estimated_speed}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<option value="fast">Fast</option>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="detailed">Detailed</option>
|
||||
<option value="full">Full</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
{presets[settings.preset || 'standard'] && (
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-dark-muted">
|
||||
{presets[settings.preset || 'standard'].description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Propagation Toggles */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-1 text-xs font-medium text-gray-500 dark:text-dark-muted hover:text-gray-700 dark:hover:text-dark-text transition-colors"
|
||||
>
|
||||
<span className="text-[10px]">{showAdvanced ? '▼' : '▶'}</span>
|
||||
Advanced Propagation
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-2 space-y-1.5 pl-1">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<label
|
||||
key={key}
|
||||
className={`flex items-center gap-2 cursor-pointer text-sm ${
|
||||
disabled || isCalculating
|
||||
? 'text-gray-400 dark:text-dark-muted cursor-not-allowed'
|
||||
: 'text-gray-700 dark:text-dark-text'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings[key] ?? false}
|
||||
onChange={(e) => {
|
||||
useCoverageStore.getState().updateSettings({
|
||||
[key]: e.target.checked,
|
||||
preset: undefined, // Clear preset when manually toggling
|
||||
});
|
||||
}}
|
||||
disabled={disabled || isCalculating}
|
||||
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-dark-border accent-blue-600"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NumberInput
|
||||
label="Terrain Opacity"
|
||||
value={Math.round(terrainOpacity * 100)}
|
||||
@@ -748,10 +831,22 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coverage error */}
|
||||
{coverageError && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||
<p className="text-xs text-red-600 dark:text-red-400">
|
||||
{coverageError}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coverage statistics */}
|
||||
<CoverageStats
|
||||
points={coverageResult?.points ?? []}
|
||||
resolution={settings.resolution}
|
||||
stats={coverageResult?.stats}
|
||||
calculationTime={coverageResult?.calculationTime}
|
||||
modelsUsed={coverageResult?.modelsUsed}
|
||||
/>
|
||||
|
||||
{/* Export coverage data */}
|
||||
|
||||
@@ -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<string, CoveragePoint[]>();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface HeatmapPoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
rsrp: number;
|
||||
siteId: string;
|
||||
siteId?: string;
|
||||
}
|
||||
|
||||
export class HeatmapTileRenderer {
|
||||
|
||||
@@ -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 (
|
||||
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4">
|
||||
@@ -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
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-dark-bg rounded p-2">
|
||||
<div className="text-gray-500 dark:text-dark-muted">Sites</div>
|
||||
<div className="font-semibold text-gray-800 dark:text-dark-text">
|
||||
{uniqueSites}
|
||||
{uniqueSites > 0 ? (
|
||||
<div className="bg-gray-50 dark:bg-dark-bg rounded p-2">
|
||||
<div className="text-gray-500 dark:text-dark-muted">Sites</div>
|
||||
<div className="font-semibold text-gray-800 dark:text-dark-text">
|
||||
{uniqueSites}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-50 dark:bg-dark-bg rounded p-2">
|
||||
<div className="text-gray-500 dark:text-dark-muted">Range</div>
|
||||
<div className="font-semibold text-gray-800 dark:text-dark-text text-[11px]">
|
||||
{minRSRP.toFixed(0)} / {maxRSRP.toFixed(0)} dBm
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API propagation stats */}
|
||||
{stats && (
|
||||
<div className="space-y-1.5 border-t border-gray-100 dark:border-dark-border pt-2">
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-dark-muted uppercase">
|
||||
Propagation Details
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-1.5 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 dark:text-dark-muted">Line of Sight</span>
|
||||
<span className="font-medium text-gray-700 dark:text-dark-text">
|
||||
{stats.los_percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
{stats.points_with_terrain_loss > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 dark:text-dark-muted">Terrain Loss</span>
|
||||
<span className="font-medium text-gray-700 dark:text-dark-text">
|
||||
{stats.points_with_terrain_loss}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{stats.points_with_buildings > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 dark:text-dark-muted">Building Loss</span>
|
||||
<span className="font-medium text-gray-700 dark:text-dark-text">
|
||||
{stats.points_with_buildings}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{stats.points_with_reflection_gain > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 dark:text-dark-muted">Reflections</span>
|
||||
<span className="font-medium text-gray-700 dark:text-dark-text">
|
||||
{stats.points_with_reflection_gain}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Calculation info */}
|
||||
{(calculationTime !== undefined || modelsUsed) && (
|
||||
<div className="border-t border-gray-100 dark:border-dark-border pt-2 text-xs text-gray-500 dark:text-dark-muted">
|
||||
{calculationTime !== undefined && (
|
||||
<span>
|
||||
Computed in <span className="font-medium text-gray-700 dark:text-dark-text">{calculationTime.toFixed(1)}s</span>
|
||||
</span>
|
||||
)}
|
||||
{modelsUsed && modelsUsed.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{modelsUsed.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="inline-block px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 rounded text-[10px] font-medium"
|
||||
>
|
||||
{model}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RSRP range */}
|
||||
<div className="text-xs text-gray-500 dark:text-dark-muted">
|
||||
Range: {minRSRP.toFixed(1)} to {maxRSRP.toFixed(1)} dBm
|
||||
</div>
|
||||
{!stats && (
|
||||
<div className="text-xs text-gray-500 dark:text-dark-muted">
|
||||
Range: {minRSRP.toFixed(1)} to {maxRSRP.toFixed(1)} dBm
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signal quality breakdown */}
|
||||
<div className="space-y-1.5">
|
||||
|
||||
133
frontend/src/services/api.ts
Normal file
133
frontend/src/services/api.ts
Normal file
@@ -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<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): 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().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<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();
|
||||
@@ -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<CoverageSettings>) => void;
|
||||
toggleHeatmap: () => void;
|
||||
setHeatmapVisible: (val: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
|
||||
// API-driven calculation
|
||||
calculateCoverage: () => Promise<void>;
|
||||
cancelCalculation: () => void;
|
||||
}
|
||||
|
||||
export const useCoverageStore = create<CoverageState>((set) => ({
|
||||
export const useCoverageStore = create<CoverageState>((set, get) => ({
|
||||
result: null,
|
||||
isCalculating: false,
|
||||
settings: {
|
||||
@@ -24,11 +33,20 @@ export const useCoverageStore = create<CoverageState>((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<CoverageState>((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 });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,6 +3,7 @@ export type {
|
||||
CoveragePoint,
|
||||
CoverageResult,
|
||||
CoverageSettings,
|
||||
CoverageApiStats,
|
||||
GridPoint,
|
||||
} from './coverage.ts';
|
||||
export type { FrequencyBand } from './frequency.ts';
|
||||
|
||||
Reference in New Issue
Block a user