import { useEffect, useState, useCallback, useRef } 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 { useToolStore } from '@/store/tools.ts'; import { useToastStore } from '@/components/ui/Toast.tsx'; import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts.ts'; import { useUnsavedChanges } from '@/hooks/useUnsavedChanges.ts'; import { logger } from '@/utils/logger.ts'; import { db } from '@/db/schema.ts'; import MapView from '@/components/map/Map.tsx'; import GeographicHeatmap from '@/components/map/GeographicHeatmap.tsx'; import WebGLCoverageLayer from '@/components/map/WebGLCoverageLayer.tsx'; import CoverageBoundary from '@/components/map/CoverageBoundary.tsx'; import HeatmapLegend from '@/components/map/HeatmapLegend.tsx'; import SiteList from '@/components/panels/SiteList.tsx'; import ExportPanel from '@/components/panels/ExportPanel.tsx'; import ProjectPanel from '@/components/panels/ProjectPanel.tsx'; import CoverageStats from '@/components/panels/CoverageStats.tsx'; import HistoryPanel from '@/components/panels/HistoryPanel.tsx'; import BatchFrequencyChange from '@/components/panels/BatchFrequencyChange.tsx'; import ResultsPanel from '@/components/panels/ResultsPanel.tsx'; import SiteImportExport from '@/components/panels/SiteImportExport.tsx'; import { SiteConfigModal } from '@/components/modals/index.ts'; import type { SiteFormValues } from '@/components/modals/index.ts'; import ToastContainer from '@/components/ui/Toast.tsx'; import ThemeToggle from '@/components/ui/ThemeToggle.tsx'; import GPUIndicator from '@/components/ui/GPUIndicator.tsx'; import TerrainProfile from '@/components/map/TerrainProfile.tsx'; import LinkBudgetPanel from '@/components/panels/LinkBudgetPanel.tsx'; import LinkBudgetOverlay from '@/components/map/LinkBudgetOverlay.tsx'; import Button from '@/components/ui/Button.tsx'; 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'; /** * Restore a sites snapshot: replace all sites in IndexedDB + Zustand. * Used by undo/redo. */ async function restoreSites(snapshot: Site[]) { // Clear current DB entries await db.sites.clear(); // Write snapshot sites to DB for (const site of snapshot) { await db.sites.put({ id: site.id, data: JSON.stringify(site), createdAt: new Date(site.createdAt).getTime(), updatedAt: new Date(site.updatedAt).getTime(), }); } // Reload from DB to sync Zustand await useSitesStore.getState().loadSites(); } export default function App() { const loadSites = useSitesStore((s) => s.loadSites); const sites = useSitesStore((s) => s.sites); const selectedSiteId = useSitesStore((s) => s.selectedSiteId); const coverageResult = useCoverageStore((s) => s.result); const isCalculating = useCoverageStore((s) => s.isCalculating); 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 partialPoints = useCoverageStore((s) => s.partialPoints); 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); // Elapsed time counter during calculation const [elapsed, setElapsed] = useState(0); useEffect(() => { if (!isCalculating) { setElapsed(0); return; } const start = Date.now(); const interval = setInterval(() => { setElapsed(Math.floor((Date.now() - start) / 1000)); }, 1000); return () => clearInterval(interval); }, [isCalculating]); // Load presets on mount useEffect(() => { api.getPresets().then(setPresets).catch((err) => { logger.warn('Failed to load presets:', err); }); }, []); // 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); const terrainOpacity = useSettingsStore((s) => s.terrainOpacity); const setTerrainOpacity = useSettingsStore((s) => s.setTerrainOpacity); const showGrid = useSettingsStore((s) => s.showGrid); const setShowGrid = useSettingsStore((s) => s.setShowGrid); const showElevationInfo = useSettingsStore((s) => s.showElevationInfo); // Tool store (centralized active tool state) const activeTool = useToolStore((s) => s.activeTool); const setActiveTool = useToolStore((s) => s.setActiveTool); const clearTool = useToolStore((s) => s.clearTool); const setShowElevationInfo = useSettingsStore((s) => s.setShowElevationInfo); const showBoundary = useSettingsStore((s) => s.showBoundary); const showElevationOverlay = useSettingsStore((s) => s.showElevationOverlay); const setShowElevationOverlay = useSettingsStore((s) => s.setShowElevationOverlay); const elevationOpacity = useSettingsStore((s) => s.elevationOpacity); const setElevationOpacity = useSettingsStore((s) => s.setElevationOpacity); const useWebGLCoverage = useSettingsStore((s) => s.useWebGLCoverage); const setUseWebGLCoverage = useSettingsStore((s) => s.setUseWebGLCoverage); // History (undo/redo) const canUndo = useHistoryStore((s) => s.canUndo); const canRedo = useHistoryStore((s) => s.canRedo); // Unsaved changes detection + beforeunload useUnsavedChanges(); const [modalState, setModalState] = useState<{ isOpen: boolean; mode: 'create' | 'edit'; editSiteId?: string; initialData?: Partial; }>({ isOpen: false, mode: 'create' }); const [panelCollapsed, setPanelCollapsed] = useState(false); const [showShortcuts, setShowShortcuts] = useState(false); const [kbDeleteTarget, setKbDeleteTarget] = useState<{ id: string; name: string } | null>(null); const [profileEndpoints, setProfileEndpoints] = useState<{ start: [number, number]; end: [number, number] } | null>(null); const [showLinkBudget, setShowLinkBudget] = useState(false); const [linkBudgetRxPoint, setLinkBudgetRxPoint] = useState<{ lat: number; lon: number } | null>(null); // Region wizard for first-run (desktop mode only) const [showWizard, setShowWizard] = useState(false); const [cachedRegions, setCachedRegions] = useState([]); const [cacheStats, setCacheStats] = useState(null); const refreshCacheStatus = useCallback(() => { api.getRegions().then(setCachedRegions).catch(() => {}); api.getCacheStats().then(setCacheStats).catch(() => {}); }, []); useEffect(() => { // Load cache status on mount refreshCacheStatus(); if (!isDesktop()) return; const skipped = localStorage.getItem('rfcp_region_wizard_skipped'); if (skipped) return; api.getRegions() .then((regions) => { const hasDownloaded = regions.some((r) => r.downloaded); if (!hasDownloaded) { setShowWizard(true); } }) .catch(() => { // Backend not ready yet, skip wizard }); }, [refreshCacheStatus]); // Resizable sidebar const PANEL_MIN = 300; const PANEL_MAX = 600; const PANEL_DEFAULT = 380; const [panelWidth, setPanelWidth] = useState(() => { const saved = localStorage.getItem('rfcp-panel-width'); const n = saved ? Number(saved) : PANEL_DEFAULT; return n >= PANEL_MIN && n <= PANEL_MAX ? n : PANEL_DEFAULT; }); const isDragging = useRef(false); const handleDragStart = useCallback((e: React.MouseEvent) => { e.preventDefault(); isDragging.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; const onMove = (ev: MouseEvent) => { if (!isDragging.current) return; const newWidth = window.innerWidth - ev.clientX; const clamped = Math.max(PANEL_MIN, Math.min(PANEL_MAX, newWidth)); setPanelWidth(clamped); }; const onUp = () => { isDragging.current = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); setPanelWidth((w) => { localStorage.setItem('rfcp-panel-width', String(w)); return w; }); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }, []); // Load sites from IndexedDB on mount useEffect(() => { loadSites(); }, [loadSites]); // Handle site placement from map click const handleSitePlacement = useCallback( (lat: number, lon: number) => { setModalState({ isOpen: true, mode: 'create', initialData: { lat, lon }, }); // Tool store clearTool() is called by MapClickHandler after placement }, [] ); // Handle RX point placement for Link Budget const handleRxPlacement = useCallback( (lat: number, lon: number) => { setLinkBudgetRxPoint({ lat, lon }); // Tool store clearTool() is called by MapClickHandler after placement }, [] ); const handleEditSite = useCallback((site: Site) => { setModalState({ isOpen: true, mode: 'edit', editSiteId: site.id, initialData: { name: site.name, lat: site.lat, lon: site.lon, power: site.power, gain: site.gain, frequency: site.frequency, height: site.height, antennaType: site.antennaType, azimuth: site.azimuth ?? 0, beamwidth: site.beamwidth ?? 65, notes: site.notes ?? '', }, }); }, []); const handleAddManual = useCallback(() => { setModalState({ isOpen: true, mode: 'create', }); }, []); const handleCloseModal = useCallback(() => { setModalState((prev) => ({ ...prev, isOpen: false })); }, []); // Modal save handler const handleModalSave = useCallback(async (data: SiteFormValues) => { const addSite = useSitesStore.getState().addSite; const updateSite = useSitesStore.getState().updateSite; if (modalState.mode === 'edit' && modalState.editSiteId) { await updateSite(modalState.editSiteId, { name: data.name, lat: data.lat, lon: data.lon, power: data.power, gain: data.gain, frequency: data.frequency, height: data.height, antennaType: data.antennaType, azimuth: data.antennaType === 'sector' ? data.azimuth : undefined, beamwidth: data.antennaType === 'sector' ? data.beamwidth : undefined, notes: data.notes || undefined, }); addToast('Site updated', 'success'); } else { await addSite({ name: data.name, lat: data.lat, lon: data.lon, power: data.power, gain: data.gain, frequency: data.frequency, height: data.height, antennaType: data.antennaType, azimuth: data.antennaType === 'sector' ? data.azimuth : undefined, beamwidth: data.antennaType === 'sector' ? data.beamwidth : undefined, color: '', visible: true, notes: data.notes || undefined, }); addToast('Site added', 'success'); } handleCloseModal(); }, [modalState.mode, modalState.editSiteId, addToast, handleCloseModal]); const handleModalDelete = useCallback(async () => { if (modalState.editSiteId) { const site = sites.find((s) => s.id === modalState.editSiteId); await useSitesStore.getState().deleteSite(modalState.editSiteId); if (site) { addToast(`"${site.name}" deleted`, 'info'); } handleCloseModal(); } }, [modalState.editSiteId, sites, addToast, handleCloseModal]); // Keyboard delete confirmation handler const handleKbDeleteConfirmed = useCallback(async () => { if (!kbDeleteTarget) return; const { id, name } = kbDeleteTarget; const siteData = sites.find((s) => s.id === id); setKbDeleteTarget(null); await useSitesStore.getState().deleteSite(id); if (siteData) { addToast(`"${name}" deleted`, 'info', { duration: 10000, action: { label: 'Undo', onClick: async () => { await useSitesStore.getState().addSite({ name: siteData.name, lat: siteData.lat, lon: siteData.lon, height: siteData.height, power: siteData.power, gain: siteData.gain, frequency: siteData.frequency, antennaType: siteData.antennaType, azimuth: siteData.azimuth, beamwidth: siteData.beamwidth, color: siteData.color, visible: siteData.visible, notes: siteData.notes, equipment: siteData.equipment, }); addToast(`"${siteData.name}" restored`, 'success'); }, }, }); } }, [kbDeleteTarget, sites, addToast]); // === Undo / Redo === const handleUndo = useCallback(async () => { const snapshot = useHistoryStore.getState().undo(); if (!snapshot) return; // Save current state to redo stack before restoring const currentSites = useSitesStore.getState().sites; const currentSettings = useCoverageStore.getState().settings; pushToFuture({ sites: structuredClone(currentSites), settings: { ...currentSettings }, timestamp: Date.now(), action: 'before undo', }); // Restore sites from snapshot await restoreSites(snapshot.sites); useCoverageStore.getState().updateSettings(snapshot.settings); useCoverageStore.getState().clearCoverage(); addToast('Undo', 'info'); }, [addToast]); const handleRedo = useCallback(async () => { const snapshot = useHistoryStore.getState().redo(); if (!snapshot) return; // Save current state to undo stack before restoring const currentSites = useSitesStore.getState().sites; const currentSettings = useCoverageStore.getState().settings; pushToPast({ sites: structuredClone(currentSites), settings: { ...currentSettings }, timestamp: Date.now(), action: 'before redo', }); // Restore sites from snapshot await restoreSites(snapshot.sites); useCoverageStore.getState().updateSettings(snapshot.settings); useCoverageStore.getState().clearCoverage(); addToast('Redo', 'info'); }, [addToast]); // Calculate coverage via backend API const handleCalculate = useCallback(async () => { const currentSites = useSitesStore.getState().sites; if (currentSites.length === 0) { addToast('Add at least one site to calculate coverage', 'error'); return; } const currentSettings = useCoverageStore.getState().settings; // Validation if (currentSettings.radius > 50) { addToast('Radius too large (max 50km)', 'error'); return; } if (currentSettings.resolution < 50) { addToast('Resolution too fine (min 50m)', 'error'); return; } try { await calculateCoverageApi(); // After calculateCoverageApi returns, check if WS took over. // In WS mode, the function returns immediately and result arrives asynchronously. const state = useCoverageStore.getState(); if (state.isCalculating && state.activeCalcId) { // WebSocket mode — toast will be shown from the WS onResult callback return; } // HTTP mode — result is ready now const result = state.result; const error = state.error; 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 firstSite = sites.find((s) => s.visible); const freqStr = firstSite ? ` \u2022 ${firstSite.frequency} MHz` : ''; const presetStr = settings.preset ? ` \u2022 ${settings.preset}` : ''; const modelsStr = result.modelsUsed?.length ? ` \u2022 ${result.modelsUsed.length} models` : ''; addToast( `${result.totalPoints.toLocaleString()} pts \u2022 ${timeStr}s${presetStr}${freqStr}${modelsStr}`, 'success' ); } } } catch (err) { logger.error('Coverage calculation error:', err); const msg = err instanceof Error ? err.message : 'Unknown error'; addToast(`Calculation failed: ${msg}`, 'error'); } }, [calculateCoverageApi, addToast]); // Save site from modal and trigger calculation const handleModalSaveAndCalculate = useCallback(async (data: SiteFormValues) => { await handleModalSave(data); setTimeout(() => handleCalculate(), 50); }, [handleModalSave, handleCalculate]); // Keyboard shortcuts useKeyboardShortcuts({ onCalculate: handleCalculate, onCloseForm: handleCloseModal, onShowShortcuts: useCallback(() => setShowShortcuts(true), []), onDeleteRequest: useCallback((id: string, name: string) => { setKbDeleteTarget({ id, name }); }, []), onUndo: handleUndo, onRedo: handleRedo, }); return (
{/* Header */}
RFCP RF Coverage Planner
{/* Undo / Redo buttons */}
{isCalculating ? ( ) : ( )}
{/* Shortcuts modal */} {showShortcuts && (
setShowShortcuts(false)} >
e.stopPropagation()} >

Keyboard Shortcuts

{/* Coverage */}

Coverage

  • Calculate coverage Ctrl+Enter
  • Clear coverage Shift+C
  • Toggle heatmap H
{/* Sites */}

Sites

  • New site (place mode) Shift+S
  • Delete selected Delete
{/* View */}

View

  • Toggle grid G
  • Toggle terrain T
  • Toggle ruler R
  • Fit to coverage F
{/* Edit */}

Edit

  • Undo Ctrl+Z
  • Redo Ctrl+Shift+Z
{/* General */}

General

  • Cancel / Close Esc
  • Show shortcuts ?
)} {/* Main content */}
{/* Map */}
setProfileEndpoints({ start, end })} showLinkBudget={showLinkBudget} onToggleLinkBudget={() => setShowLinkBudget(!showLinkBudget)} > {/* Show partial results during tiled calculation, or final result */} {(coverageResult || (isCalculating && partialPoints.length > 0)) && ( <> {/* Only render ONE layer - WebGL or Canvas, never both */} {useWebGLCoverage && ( 0 ? partialPoints : (coverageResult?.points ?? [])} visible={heatmapVisible} opacity={settings.heatmapOpacity} minRsrp={-130} maxRsrp={-50} onWebGLFailed={() => setUseWebGLCoverage(false)} /> )} {!useWebGLCoverage && ( 0 ? partialPoints : (coverageResult?.points ?? [])} visible={heatmapVisible} opacity={settings.heatmapOpacity} radiusMeters={settings.heatmapRadius} rsrpThreshold={settings.rsrpThreshold} /> )} {coverageResult && ( p.rsrp >= settings.rsrpThreshold)} visible={showBoundary} resolution={settings.resolution} boundary={coverageResult.boundary} /> )} )} {/* Link Budget TX-RX overlay */} {showLinkBudget && linkBudgetRxPoint && (() => { const txSite = sites.find(s => s.id === selectedSiteId); return ( setLinkBudgetRxPoint({ lat, lon })} /> ); })()} {activeTool === 'rx-placement' && (
Click on map to set RX point
)} {profileEndpoints && ( setProfileEndpoints(null)} /> )} {showLinkBudget && (
setActiveTool('rx-placement')} onClose={() => { setShowLinkBudget(false); clearTool(); setLinkBudgetRxPoint(null); }} />
)}
{/* Side panel */}
= 640 ? panelWidth : undefined }} > {/* Resize drag handle (desktop only) */}
{/* Mobile drag handle + close */}
{/* Site list */} {/* Quick frequency change */}
{/* Coverage settings */}

Coverage Settings

{ const clamped = Math.min(v, 50); useCoverageStore.getState().updateSettings({ radius: clamped }); }} min={1} max={50} step={5} unit="km" hint="Calculation area around each site (max 50km)" /> useCoverageStore.getState().updateSettings({ resolution: v }) } min={50} max={500} step={50} unit="m" hint="Grid spacing — lower = more accurate but slower" /> useCoverageStore.getState().updateSettings({ rsrpThreshold: v }) } min={-140} max={-50} step={5} unit="dBm" hint="RSRP threshold — points below this are hidden" /> useCoverageStore.getState().updateSettings({ heatmapOpacity: v / 100 }) } min={30} max={100} step={5} unit="%" hint="Transparency of the RF coverage overlay" />

WebGL interpolation for smooth gradients

{!useWebGLCoverage && (

Pixel radius per point — larger = smoother, smaller = sharper

{settings.heatmapRadius >= 600 && settings.resolution > 200 && (

Wide radius works best with fine resolution (200m or less). Current: {settings.resolution}m

)}
)} {/* Propagation Model Preset */}
{presets[settings.preset || 'standard'] && (

{presets[settings.preset || 'standard'].description}

)}
{/* Advanced Propagation Toggles */}
{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 }, { key: 'use_water_reflection' as const, label: 'Water Reflection', disabled: false }, { key: 'use_vegetation' as const, label: 'Vegetation Loss', disabled: false }, ].map(({ key, label, disabled }) => ( ))} {/* Season selector (only relevant when vegetation is enabled) */} {settings.use_vegetation && (
)} {/* Atmospheric absorption toggle */} {settings.use_atmospheric && (
)} {/* Weather / Rain section */}

Environment

{/* Indoor penetration section */}
{/* Fading margin */}
useCoverageStore.getState().updateSettings({ fading_margin: v })} min={0} max={20} step={1} unit="dB" hint="Safety margin subtracted from signal" />
)}
setTerrainOpacity(v / 100)} min={10} max={100} step={5} unit="%" hint={!showTerrain ? 'Enable terrain overlay first' : undefined} />
{/* Map Tools */}

Map Tools

{activeTool === 'ruler' && (

Click start and end points. Esc to cancel.

)} {showElevationOverlay && (
setElevationOpacity(v / 100)} min={10} max={100} step={10} unit="%" />
)}
{/* Data Cache Status */}

Data Cache

{cachedRegions.length > 0 ? ( <>
{cachedRegions.filter((r) => r.downloaded || r.download_progress > 0).length > 0 ? ( cachedRegions .filter((r) => r.downloaded || r.download_progress > 0) .map((r) => (
{r.name} {!r.downloaded && ( {Math.round(r.download_progress)}% )}
)) ) : (

No regions cached

)}
{cacheStats && (

{cacheStats.terrain_tiles} terrain tiles ({cacheStats.terrain_mb} MB)

)} ) : (

Loading...

)}
{isDesktop() && ( )}
{/* Coverage error */} {coverageError && (

{coverageError}

)} {/* Coverage statistics */} {/* Session history */} {/* Export coverage data */} {/* Site import/export */} {/* Projects save/load */}
{/* Site configuration modal */} {/* Keyboard delete confirmation dialog */} {kbDeleteTarget && ( setKbDeleteTarget(null)} /> )} {/* First-run region download wizard (desktop only) */} {showWizard && ( { setShowWizard(false); refreshCacheStatus(); }} /> )}
); }