@mytec: feat: Phase 3.0 Architecture Refactor ✅
Major refactoring of RFCP backend: - Modular propagation models (8 models) - SharedMemoryManager for terrain data - ProcessPoolExecutor parallel processing - WebSocket progress streaming - Building filtering pipeline (351k → 15k) - 82 unit tests Performance: Standard preset 38s → 5s (7.6x speedup) Known issue: Detailed preset timeout (fix in 3.1.0)
This commit is contained in:
@@ -29,6 +29,7 @@ import NumberInput from '@/components/ui/NumberInput.tsx';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx';
|
||||
import { RegionWizard } from '@/components/RegionWizard.tsx';
|
||||
import { isDesktop, getDesktopApi } from '@/lib/desktop.ts';
|
||||
import { wsService } from '@/services/websocket.ts';
|
||||
import type { RegionInfo, CacheStats } from '@/services/api.ts';
|
||||
|
||||
/**
|
||||
@@ -61,6 +62,7 @@ export default function App() {
|
||||
const settings = useCoverageStore((s) => s.settings);
|
||||
const heatmapVisible = useCoverageStore((s) => s.heatmapVisible);
|
||||
const coverageError = useCoverageStore((s) => s.error);
|
||||
const coverageProgress = useCoverageStore((s) => s.progress);
|
||||
const calculateCoverageApi = useCoverageStore((s) => s.calculateCoverage);
|
||||
const cancelCalculation = useCoverageStore((s) => s.cancelCalculation);
|
||||
|
||||
@@ -89,6 +91,12 @@ export default function App() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Connect WebSocket for real-time coverage progress
|
||||
useEffect(() => {
|
||||
wsService.connect();
|
||||
return () => wsService.disconnect();
|
||||
}, []);
|
||||
|
||||
const addToast = useToastStore((s) => s.addToast);
|
||||
|
||||
const showTerrain = useSettingsStore((s) => s.showTerrain);
|
||||
@@ -504,7 +512,9 @@ export default function App() {
|
||||
>
|
||||
<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" />
|
||||
Cancel{elapsed > 0 ? ` (${elapsed}s)` : '...'}
|
||||
{coverageProgress
|
||||
? `${coverageProgress.phase} ${Math.round(coverageProgress.progress * 100)}%`
|
||||
: `Cancel${elapsed > 0 ? ` (${elapsed}s)` : '...'}`}
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import NumberInput from '@/components/ui/NumberInput.tsx';
|
||||
import FrequencySelector from '@/components/panels/FrequencySelector.tsx';
|
||||
import FrequencyBandPanel from '@/components/panels/FrequencyBandPanel.tsx';
|
||||
import ModalBackdrop from './ModalBackdrop.tsx';
|
||||
|
||||
export interface SiteFormValues {
|
||||
@@ -104,6 +105,26 @@ const TEMPLATES = {
|
||||
height: 3,
|
||||
antennaType: 'omni' as const,
|
||||
},
|
||||
uhfTactical: {
|
||||
label: 'UHF Tactical',
|
||||
style: 'amber',
|
||||
name: 'UHF Tactical Radio',
|
||||
power: 25,
|
||||
gain: 3,
|
||||
frequency: 450,
|
||||
height: 5,
|
||||
antennaType: 'omni' as const,
|
||||
},
|
||||
vhfRepeater: {
|
||||
label: 'VHF Repeater',
|
||||
style: 'teal',
|
||||
name: 'VHF Repeater',
|
||||
power: 40,
|
||||
gain: 6,
|
||||
frequency: 150,
|
||||
height: 25,
|
||||
antennaType: 'omni' as const,
|
||||
},
|
||||
};
|
||||
|
||||
const TEMPLATE_COLORS: Record<string, string> = {
|
||||
@@ -114,6 +135,8 @@ const TEMPLATE_COLORS: Record<string, string> = {
|
||||
emerald: 'bg-emerald-100 hover:bg-emerald-200 text-emerald-700 dark:bg-emerald-900/30 dark:hover:bg-emerald-900/50 dark:text-emerald-300',
|
||||
cyan: 'bg-cyan-100 hover:bg-cyan-200 text-cyan-700 dark:bg-cyan-900/30 dark:hover:bg-cyan-900/50 dark:text-cyan-300',
|
||||
rose: 'bg-rose-100 hover:bg-rose-200 text-rose-700 dark:bg-rose-900/30 dark:hover:bg-rose-900/50 dark:text-rose-300',
|
||||
amber: 'bg-amber-100 hover:bg-amber-200 text-amber-700 dark:bg-amber-900/30 dark:hover:bg-amber-900/50 dark:text-amber-300',
|
||||
teal: 'bg-teal-100 hover:bg-teal-200 text-teal-700 dark:bg-teal-900/30 dark:hover:bg-teal-900/50 dark:text-teal-300',
|
||||
};
|
||||
|
||||
function getDefaults(initialData?: Partial<SiteFormValues>): SiteFormValues {
|
||||
@@ -350,6 +373,12 @@ export default function SiteConfigModal({
|
||||
onChange={(v) => updateField('frequency', v)}
|
||||
/>
|
||||
|
||||
{/* Band panel — UHF/VHF/LTE/5G grouped selector */}
|
||||
<FrequencyBandPanel
|
||||
value={form.frequency}
|
||||
onChange={(v) => updateField('frequency', v)}
|
||||
/>
|
||||
|
||||
{/* Physical Parameters separator */}
|
||||
<div className="border-t border-gray-200 dark:border-white/10 pt-3">
|
||||
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
|
||||
144
frontend/src/components/panels/FrequencyBandPanel.tsx
Normal file
144
frontend/src/components/panels/FrequencyBandPanel.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* UHF/VHF frequency band panel for tactical radio planning.
|
||||
*
|
||||
* Shows band-grouped frequency selection with tactical presets
|
||||
* and propagation model info for each band.
|
||||
*/
|
||||
|
||||
import { COMMON_FREQUENCIES, FREQUENCY_GROUPS, getWavelength } from '@/constants/frequencies.ts';
|
||||
import type { FrequencyBand } from '@/types/index.ts';
|
||||
|
||||
interface FrequencyBandPanelProps {
|
||||
value: number;
|
||||
onChange: (freq: number) => void;
|
||||
}
|
||||
|
||||
const BAND_COLORS: Record<string, { bg: string; text: string; border: string; active: string }> = {
|
||||
VHF: {
|
||||
bg: 'bg-green-50 dark:bg-green-900/20',
|
||||
text: 'text-green-700 dark:text-green-300',
|
||||
border: 'border-green-200 dark:border-green-800',
|
||||
active: 'bg-green-600 dark:bg-green-500',
|
||||
},
|
||||
UHF: {
|
||||
bg: 'bg-amber-50 dark:bg-amber-900/20',
|
||||
text: 'text-amber-700 dark:text-amber-300',
|
||||
border: 'border-amber-200 dark:border-amber-800',
|
||||
active: 'bg-amber-600 dark:bg-amber-500',
|
||||
},
|
||||
LTE: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
text: 'text-blue-700 dark:text-blue-300',
|
||||
border: 'border-blue-200 dark:border-blue-800',
|
||||
active: 'bg-blue-600 dark:bg-blue-500',
|
||||
},
|
||||
'5G': {
|
||||
bg: 'bg-purple-50 dark:bg-purple-900/20',
|
||||
text: 'text-purple-700 dark:text-purple-300',
|
||||
border: 'border-purple-200 dark:border-purple-800',
|
||||
active: 'bg-purple-600 dark:bg-purple-500',
|
||||
},
|
||||
};
|
||||
|
||||
const PROPAGATION_MODELS: Record<string, string> = {
|
||||
VHF: 'ITU-R P.1546 / Longley-Rice',
|
||||
UHF: 'Okumura-Hata / Longley-Rice',
|
||||
LTE: 'COST-231 Hata / Okumura-Hata',
|
||||
'5G': 'Free-Space / COST-231',
|
||||
};
|
||||
|
||||
function getBandForFrequency(freq: number): string | null {
|
||||
const band = COMMON_FREQUENCIES.find((b) => Math.abs(b.value - freq) < 50);
|
||||
return band?.type ?? null;
|
||||
}
|
||||
|
||||
export default function FrequencyBandPanel({ value, onChange }: FrequencyBandPanelProps) {
|
||||
const currentBand = getBandForFrequency(value);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-semibold text-gray-500 dark:text-dark-muted uppercase tracking-wide">
|
||||
Frequency Bands
|
||||
</div>
|
||||
|
||||
{(Object.keys(FREQUENCY_GROUPS) as Array<keyof typeof FREQUENCY_GROUPS>).map((bandType) => {
|
||||
const freqs = FREQUENCY_GROUPS[bandType];
|
||||
const colors = BAND_COLORS[bandType] ?? BAND_COLORS.LTE;
|
||||
const bandFrequencies = COMMON_FREQUENCIES.filter(
|
||||
(b) => b.type === bandType
|
||||
);
|
||||
const isActiveBand = currentBand === bandType;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={bandType}
|
||||
className={`rounded-lg border p-2.5 ${
|
||||
isActiveBand
|
||||
? `${colors.border} ${colors.bg}`
|
||||
: 'border-gray-200 dark:border-dark-border'
|
||||
}`}
|
||||
>
|
||||
{/* Band header */}
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className={`text-xs font-bold ${isActiveBand ? colors.text : 'text-gray-600 dark:text-dark-muted'}`}>
|
||||
{bandType}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 dark:text-dark-muted">
|
||||
{PROPAGATION_MODELS[bandType]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Frequency buttons */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(freqs as readonly number[]).map((freq) => {
|
||||
const isActive = value === freq;
|
||||
const bandInfo = bandFrequencies.find(
|
||||
(b: FrequencyBand) => b.value === freq
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={freq}
|
||||
type="button"
|
||||
onClick={() => onChange(freq)}
|
||||
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors min-h-[28px]
|
||||
${
|
||||
isActive
|
||||
? `${colors.active} text-white`
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-dark-border dark:text-dark-text dark:hover:bg-dark-muted'
|
||||
}`}
|
||||
title={bandInfo ? `${bandInfo.name}: ${bandInfo.range}` : `${freq} MHz`}
|
||||
>
|
||||
{freq}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<span className="self-center text-[10px] text-gray-400 dark:text-dark-muted ml-0.5">MHz</span>
|
||||
</div>
|
||||
|
||||
{/* Active band details */}
|
||||
{isActiveBand && bandFrequencies.length > 0 && (
|
||||
<div className={`mt-1.5 text-[11px] ${colors.text}`}>
|
||||
{(() => {
|
||||
const info = bandFrequencies.find((b: FrequencyBand) => Math.abs(b.value - value) < 50);
|
||||
if (!info) return null;
|
||||
return (
|
||||
<>
|
||||
<span className="font-medium">{info.name}</span>
|
||||
{' · '}
|
||||
{info.range}
|
||||
{' · '}
|
||||
λ={getWavelength(value)}
|
||||
{' · '}
|
||||
{info.characteristics.typical}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -93,6 +93,17 @@ export const COMMON_FREQUENCIES: FrequencyBand[] = [
|
||||
|
||||
export const QUICK_FREQUENCIES = [800, 1800, 1900, 2100, 2600];
|
||||
|
||||
// Tactical radio presets for UHF/VHF
|
||||
export const TACTICAL_FREQUENCIES = [150, 450];
|
||||
|
||||
// All quick frequencies grouped by band type
|
||||
export const FREQUENCY_GROUPS = {
|
||||
LTE: [800, 1800, 1900, 2100, 2600],
|
||||
UHF: [450],
|
||||
VHF: [150],
|
||||
'5G': [3500],
|
||||
} as const;
|
||||
|
||||
export function getFrequencyInfo(frequency: number): FrequencyBand | null {
|
||||
return (
|
||||
COMMON_FREQUENCIES.find((band) => Math.abs(band.value - frequency) < 50) ||
|
||||
|
||||
30
frontend/src/hooks/useWebSocket.ts
Normal file
30
frontend/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* React hook wrapping the WebSocket singleton service.
|
||||
*
|
||||
* Provides reactive connection state and progress for UI components.
|
||||
* The actual WS connection is managed by wsService singleton so it
|
||||
* persists across component remounts.
|
||||
*
|
||||
* Usage:
|
||||
* const { connected, progress } = useWebSocket();
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { wsService } from '@/services/websocket.ts';
|
||||
import { useCoverageStore } from '@/store/coverage.ts';
|
||||
|
||||
export function useWebSocket() {
|
||||
const [connected, setConnected] = useState(wsService.connected);
|
||||
const progress = useCoverageStore((s) => s.progress);
|
||||
|
||||
useEffect(() => {
|
||||
// Connect the singleton if not already connected
|
||||
wsService.connect();
|
||||
|
||||
// Subscribe to connection state changes
|
||||
const unsub = wsService.onConnectionChange(setConnected);
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
return { connected, progress };
|
||||
}
|
||||
156
frontend/src/services/websocket.ts
Normal file
156
frontend/src/services/websocket.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Singleton WebSocket service for real-time coverage calculation.
|
||||
*
|
||||
* Manages a persistent WebSocket connection and provides calculate/cancel
|
||||
* methods usable from Zustand stores (not just React components).
|
||||
*
|
||||
* Usage:
|
||||
* import { wsService } from '@/services/websocket';
|
||||
* wsService.connect();
|
||||
* wsService.calculate(sites, settings, onResult, onError, onProgress);
|
||||
*/
|
||||
|
||||
import { getApiBaseUrl } from '@/lib/desktop.ts';
|
||||
import type { CoverageResponse } from '@/services/api.ts';
|
||||
|
||||
export interface WSProgress {
|
||||
phase: string;
|
||||
progress: number;
|
||||
eta_seconds?: number;
|
||||
}
|
||||
|
||||
type ProgressCallback = (progress: WSProgress) => void;
|
||||
type ResultCallback = (data: CoverageResponse) => void;
|
||||
type ErrorCallback = (error: string) => void;
|
||||
type ConnectionCallback = (connected: boolean) => void;
|
||||
|
||||
interface PendingCalc {
|
||||
onProgress?: ProgressCallback;
|
||||
onResult: ResultCallback;
|
||||
onError: ErrorCallback;
|
||||
}
|
||||
|
||||
class WebSocketService {
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private _connected = false;
|
||||
private _pendingCalcs = new Map<string, PendingCalc>();
|
||||
private _connectionListeners = new Set<ConnectionCallback>();
|
||||
|
||||
get connected(): boolean {
|
||||
return this._connected;
|
||||
}
|
||||
|
||||
/** Register a listener for connection state changes. Returns unsubscribe fn. */
|
||||
onConnectionChange(cb: ConnectionCallback): () => void {
|
||||
this._connectionListeners.add(cb);
|
||||
return () => this._connectionListeners.delete(cb);
|
||||
}
|
||||
|
||||
private _setConnected(val: boolean): void {
|
||||
this._connected = val;
|
||||
for (const cb of this._connectionListeners) {
|
||||
try { cb(val); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
||||
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) return;
|
||||
|
||||
const apiBase = getApiBaseUrl();
|
||||
const url = apiBase.replace(/\/api\/?$/, '').replace(/^http/, 'ws') + '/ws';
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(url);
|
||||
} catch {
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this._setConnected(true);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this._setConnected(false);
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), 2000);
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onclose will handle reconnect
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
const calcId: string | undefined = msg.calculation_id;
|
||||
const pending = calcId ? this._pendingCalcs.get(calcId) : undefined;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'progress':
|
||||
pending?.onProgress?.({
|
||||
phase: msg.phase,
|
||||
progress: msg.progress,
|
||||
eta_seconds: msg.eta_seconds,
|
||||
});
|
||||
break;
|
||||
case 'result':
|
||||
pending?.onResult(msg.data);
|
||||
if (calcId) this._pendingCalcs.delete(calcId);
|
||||
break;
|
||||
case 'error':
|
||||
pending?.onError(msg.message);
|
||||
if (calcId) this._pendingCalcs.delete(calcId);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
this._setConnected(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a coverage calculation request via WebSocket.
|
||||
* Returns the calculation ID, or undefined if WS is not connected.
|
||||
*/
|
||||
calculate(
|
||||
sites: Array<Record<string, unknown>>,
|
||||
settings: Record<string, unknown>,
|
||||
onResult: ResultCallback,
|
||||
onError: ErrorCallback,
|
||||
onProgress?: ProgressCallback,
|
||||
): string | undefined {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const calcId = crypto.randomUUID();
|
||||
this._pendingCalcs.set(calcId, { onProgress, onResult, onError });
|
||||
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'calculate',
|
||||
id: calcId,
|
||||
sites,
|
||||
settings,
|
||||
}));
|
||||
|
||||
return calcId;
|
||||
}
|
||||
|
||||
/** Cancel a running calculation by ID. */
|
||||
cancel(calcId: string): void {
|
||||
this.ws?.send(JSON.stringify({ type: 'cancel', id: calcId }));
|
||||
this._pendingCalcs.delete(calcId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton WebSocket service. */
|
||||
export const wsService = new WebSocketService();
|
||||
@@ -1,8 +1,10 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '@/services/api.ts';
|
||||
import { wsService } from '@/services/websocket.ts';
|
||||
import type { WSProgress } from '@/services/websocket.ts';
|
||||
import { useSitesStore } from '@/store/sites.ts';
|
||||
import type { CoverageResult, CoverageSettings, CoverageApiStats } from '@/types/index.ts';
|
||||
import type { ApiSiteParams } from '@/services/api.ts';
|
||||
import type { ApiSiteParams, CoverageResponse } from '@/services/api.ts';
|
||||
|
||||
interface CoverageState {
|
||||
result: CoverageResult | null;
|
||||
@@ -11,6 +13,10 @@ interface CoverageState {
|
||||
heatmapVisible: boolean;
|
||||
error: string | null;
|
||||
|
||||
// WebSocket progress (null when not calculating or using HTTP)
|
||||
progress: WSProgress | null;
|
||||
activeCalcId: string | null;
|
||||
|
||||
setResult: (result: CoverageResult | null) => void;
|
||||
clearCoverage: () => void;
|
||||
setIsCalculating: (val: boolean) => void;
|
||||
@@ -24,6 +30,68 @@ interface CoverageState {
|
||||
cancelCalculation: () => void;
|
||||
}
|
||||
|
||||
function buildApiSites(): ApiSiteParams[] {
|
||||
return useSitesStore.getState().sites
|
||||
.filter((s) => s.visible)
|
||||
.map((site) => ({
|
||||
lat: site.lat,
|
||||
lon: site.lon,
|
||||
height: site.height,
|
||||
power: site.power,
|
||||
gain: site.gain,
|
||||
frequency: site.frequency,
|
||||
azimuth: site.antennaType === 'sector' ? site.azimuth : undefined,
|
||||
beamwidth: site.antennaType === 'sector' ? site.beamwidth : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildApiSettings(settings: CoverageSettings) {
|
||||
return {
|
||||
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,
|
||||
use_water_reflection: settings.use_water_reflection,
|
||||
use_vegetation: settings.use_vegetation,
|
||||
season: settings.season,
|
||||
rain_rate: settings.rain_rate,
|
||||
indoor_loss_type: settings.indoor_loss_type,
|
||||
use_atmospheric: settings.use_atmospheric,
|
||||
temperature_c: settings.temperature_c,
|
||||
humidity_percent: settings.humidity_percent,
|
||||
};
|
||||
}
|
||||
|
||||
function responseToResult(response: CoverageResponse, settings: CoverageSettings): CoverageResult {
|
||||
return {
|
||||
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,
|
||||
vegetation_loss: p.vegetation_loss,
|
||||
rain_loss: p.rain_loss,
|
||||
indoor_loss: p.indoor_loss,
|
||||
atmospheric_loss: p.atmospheric_loss,
|
||||
})),
|
||||
calculationTime: response.computation_time,
|
||||
totalPoints: response.count,
|
||||
settings: settings,
|
||||
stats: response.stats as CoverageApiStats,
|
||||
modelsUsed: response.models_used,
|
||||
};
|
||||
}
|
||||
|
||||
export const useCoverageStore = create<CoverageState>((set, get) => ({
|
||||
result: null,
|
||||
isCalculating: false,
|
||||
@@ -55,6 +123,8 @@ export const useCoverageStore = create<CoverageState>((set, get) => ({
|
||||
},
|
||||
heatmapVisible: true,
|
||||
error: null,
|
||||
progress: null,
|
||||
activeCalcId: null,
|
||||
|
||||
setResult: (result) => set({ result }),
|
||||
clearCoverage: () => set({ result: null, error: null }),
|
||||
@@ -76,81 +146,51 @@ export const useCoverageStore = create<CoverageState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isCalculating: true, error: null });
|
||||
const apiSites = buildApiSites();
|
||||
if (apiSites.length === 0) {
|
||||
set({ error: 'No visible sites to calculate' });
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
const apiSettings = buildApiSettings(settings);
|
||||
|
||||
if (apiSites.length === 0) {
|
||||
set({ isCalculating: false, error: 'No visible sites to calculate' });
|
||||
return;
|
||||
set({ isCalculating: true, error: null, progress: null, activeCalcId: null });
|
||||
|
||||
// Try WebSocket first (provides real-time progress)
|
||||
if (wsService.connected) {
|
||||
const calcId = wsService.calculate(
|
||||
apiSites as unknown as Array<Record<string, unknown>>,
|
||||
apiSettings as unknown as Record<string, unknown>,
|
||||
// onResult
|
||||
(data) => {
|
||||
const result = responseToResult(data, settings);
|
||||
set({ result, isCalculating: false, error: null, progress: null, activeCalcId: null });
|
||||
},
|
||||
// onError
|
||||
(error) => {
|
||||
set({ isCalculating: false, error, progress: null, activeCalcId: null });
|
||||
},
|
||||
// onProgress
|
||||
(progress) => {
|
||||
set({ progress });
|
||||
},
|
||||
);
|
||||
|
||||
if (calcId) {
|
||||
set({ activeCalcId: calcId });
|
||||
return; // WS handles the rest asynchronously
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP fallback (no progress updates, waits for full result)
|
||||
try {
|
||||
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,
|
||||
use_water_reflection: settings.use_water_reflection,
|
||||
use_vegetation: settings.use_vegetation,
|
||||
season: settings.season,
|
||||
rain_rate: settings.rain_rate,
|
||||
indoor_loss_type: settings.indoor_loss_type,
|
||||
use_atmospheric: settings.use_atmospheric,
|
||||
temperature_c: settings.temperature_c,
|
||||
humidity_percent: settings.humidity_percent,
|
||||
},
|
||||
settings: apiSettings,
|
||||
});
|
||||
|
||||
// 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,
|
||||
vegetation_loss: p.vegetation_loss,
|
||||
rain_loss: p.rain_loss,
|
||||
indoor_loss: p.indoor_loss,
|
||||
atmospheric_loss: p.atmospheric_loss,
|
||||
})),
|
||||
calculationTime: response.computation_time,
|
||||
totalPoints: response.count,
|
||||
settings: settings,
|
||||
stats: response.stats as CoverageApiStats,
|
||||
modelsUsed: response.models_used,
|
||||
};
|
||||
|
||||
set({
|
||||
result,
|
||||
isCalculating: false,
|
||||
error: null,
|
||||
});
|
||||
const result = responseToResult(response, settings);
|
||||
set({ result, isCalculating: false, error: null });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
set({ isCalculating: false });
|
||||
@@ -164,7 +204,11 @@ export const useCoverageStore = create<CoverageState>((set, get) => ({
|
||||
},
|
||||
|
||||
cancelCalculation: () => {
|
||||
const { activeCalcId } = get();
|
||||
if (activeCalcId) {
|
||||
wsService.cancel(activeCalcId);
|
||||
}
|
||||
api.cancelCalculation();
|
||||
set({ isCalculating: false });
|
||||
set({ isCalculating: false, progress: null, activeCalcId: null });
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user