Files
rfcp/frontend/src/App.tsx
2026-02-06 22:17:24 +02:00

1378 lines
60 KiB
TypeScript

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<Record<string, Preset>>({});
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<SiteFormValues>;
}>({ 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<RegionInfo[]>([]);
const [cacheStats, setCacheStats] = useState<CacheStats | null>(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 (
<div className="h-screen w-screen flex flex-col bg-gray-100 dark:bg-dark-bg">
{/* Header */}
<header className="bg-slate-800 dark:bg-slate-900 text-white px-4 py-2 flex items-center justify-between flex-shrink-0 z-[1010]">
<div className="flex items-center gap-2">
<span className="text-base font-bold">RFCP</span>
<span className="text-xs text-slate-400 hidden sm:inline">
RF Coverage Planner
</span>
</div>
<div className="flex items-center gap-3 mr-4">
<GPUIndicator />
<ThemeToggle />
{/* Undo / Redo buttons */}
<div className="hidden sm:flex items-center gap-1">
<button
onClick={handleUndo}
disabled={!canUndo}
className="text-slate-400 hover:text-white disabled:text-slate-600 disabled:cursor-not-allowed p-1 transition-colors"
title="Undo (Ctrl+Z)"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
<path fillRule="evenodd" d="M7.793 2.232a.75.75 0 0 1-.025 1.06L3.622 7.25h10.003a5.375 5.375 0 0 1 0 10.75H10.75a.75.75 0 0 1 0-1.5h2.875a3.875 3.875 0 0 0 0-7.75H3.622l4.146 3.957a.75.75 0 0 1-1.036 1.085l-5.5-5.25a.75.75 0 0 1 0-1.085l5.5-5.25a.75.75 0 0 1 1.06.025Z" clipRule="evenodd" />
</svg>
</button>
<button
onClick={handleRedo}
disabled={!canRedo}
className="text-slate-400 hover:text-white disabled:text-slate-600 disabled:cursor-not-allowed p-1 transition-colors"
title="Redo (Ctrl+Shift+Z)"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
<path fillRule="evenodd" d="M12.207 2.232a.75.75 0 0 0 .025 1.06l4.146 3.958H6.375a5.375 5.375 0 0 0 0 10.75H9.25a.75.75 0 0 0 0-1.5H6.375a3.875 3.875 0 0 1 0-7.75h10.003l-4.146 3.957a.75.75 0 0 0 1.036 1.085l5.5-5.25a.75.75 0 0 0 0-1.085l-5.5-5.25a.75.75 0 0 0-1.06.025Z" clipRule="evenodd" />
</svg>
</button>
</div>
<button
onClick={() => setShowShortcuts(!showShortcuts)}
className="text-slate-400 hover:text-white text-sm hidden sm:inline"
title="Keyboard shortcuts"
>
?
</button>
{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" />
{coverageProgress
? `${coverageProgress.phase} ${Math.round(coverageProgress.progress * 100)}%`
: `Cancel${elapsed > 0 ? ` (${elapsed}s)` : '...'}`}
</span>
</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"
>
{panelCollapsed ? 'Show' : 'Hide'}
</button>
</div>
</header>
{/* Shortcuts modal */}
{showShortcuts && (
<div
className="fixed inset-0 z-[9000] bg-black/40 flex items-center justify-center"
onClick={() => setShowShortcuts(false)}
>
<div
className="bg-white dark:bg-dark-surface rounded-lg shadow-xl p-5 max-w-sm mx-4 border border-gray-200 dark:border-dark-border"
onClick={(e) => e.stopPropagation()}
>
<h3 className="font-semibold text-gray-800 dark:text-dark-text mb-3">
Keyboard Shortcuts
</h3>
<div className="space-y-3 text-sm text-gray-600 dark:text-dark-muted">
{/* Coverage */}
<div>
<h4 className="text-xs font-semibold text-gray-400 dark:text-dark-muted uppercase mb-1">Coverage</h4>
<ul className="space-y-1">
<li className="flex justify-between gap-4">
<span>Calculate coverage</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">Ctrl+Enter</kbd>
</li>
<li className="flex justify-between gap-4">
<span>Clear coverage</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">Shift+C</kbd>
</li>
<li className="flex justify-between gap-4">
<span>Toggle heatmap</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">H</kbd>
</li>
</ul>
</div>
{/* Sites */}
<div>
<h4 className="text-xs font-semibold text-gray-400 dark:text-dark-muted uppercase mb-1">Sites</h4>
<ul className="space-y-1">
<li className="flex justify-between gap-4">
<span>New site (place mode)</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">Shift+S</kbd>
</li>
<li className="flex justify-between gap-4">
<span>Delete selected</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">Delete</kbd>
</li>
</ul>
</div>
{/* View */}
<div>
<h4 className="text-xs font-semibold text-gray-400 dark:text-dark-muted uppercase mb-1">View</h4>
<ul className="space-y-1">
<li className="flex justify-between gap-4">
<span>Toggle grid</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">G</kbd>
</li>
<li className="flex justify-between gap-4">
<span>Toggle terrain</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">T</kbd>
</li>
<li className="flex justify-between gap-4">
<span>Toggle ruler</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">R</kbd>
</li>
<li className="flex justify-between gap-4">
<span>Fit to coverage</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">F</kbd>
</li>
</ul>
</div>
{/* Edit */}
<div>
<h4 className="text-xs font-semibold text-gray-400 dark:text-dark-muted uppercase mb-1">Edit</h4>
<ul className="space-y-1">
<li className="flex justify-between gap-4">
<span>Undo</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">Ctrl+Z</kbd>
</li>
<li className="flex justify-between gap-4">
<span>Redo</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">Ctrl+Shift+Z</kbd>
</li>
</ul>
</div>
{/* General */}
<div>
<h4 className="text-xs font-semibold text-gray-400 dark:text-dark-muted uppercase mb-1">General</h4>
<ul className="space-y-1">
<li className="flex justify-between gap-4">
<span>Cancel / Close</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">Esc</kbd>
</li>
<li className="flex justify-between gap-4">
<span>Show shortcuts</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">?</kbd>
</li>
</ul>
</div>
</div>
<button
onClick={() => setShowShortcuts(false)}
className="mt-4 w-full py-2 text-sm bg-gray-100 dark:bg-dark-border dark:text-dark-text rounded-md hover:bg-gray-200 dark:hover:bg-dark-muted transition-colors"
>
Close
</button>
</div>
</div>
)}
{/* Main content */}
<div className="flex-1 flex overflow-hidden relative">
{/* Map */}
<div className="flex-1 relative">
<MapView
onSitePlacement={handleSitePlacement}
onRxPlacement={handleRxPlacement}
onEditSite={handleEditSite}
onProfileRequest={(start, end) => 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 && (
<WebGLCoverageLayer
key="webgl-coverage"
points={isCalculating && partialPoints.length > 0 ? partialPoints : (coverageResult?.points ?? [])}
visible={heatmapVisible}
opacity={settings.heatmapOpacity}
minRsrp={-130}
maxRsrp={-50}
onWebGLFailed={() => setUseWebGLCoverage(false)}
/>
)}
{!useWebGLCoverage && (
<GeographicHeatmap
key="canvas-coverage"
points={isCalculating && partialPoints.length > 0 ? partialPoints : (coverageResult?.points ?? [])}
visible={heatmapVisible}
opacity={settings.heatmapOpacity}
radiusMeters={settings.heatmapRadius}
rsrpThreshold={settings.rsrpThreshold}
/>
)}
{coverageResult && (
<CoverageBoundary
points={coverageResult.points.filter(p => 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 (
<LinkBudgetOverlay
txPoint={txSite ? { lat: txSite.lat, lon: txSite.lon } : null}
rxPoint={linkBudgetRxPoint}
onRxDrag={(lat, lon) => setLinkBudgetRxPoint({ lat, lon })}
/>
);
})()}
</MapView>
{activeTool === 'rx-placement' && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[2000] bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg text-sm font-medium flex items-center gap-2">
<span>Click on map to set RX point</span>
<button
onClick={() => clearTool()}
className="text-white/70 hover:text-white ml-2"
>
Cancel
</button>
</div>
)}
<HeatmapLegend />
<ResultsPanel />
{profileEndpoints && (
<TerrainProfile
start={profileEndpoints.start}
end={profileEndpoints.end}
onClose={() => setProfileEndpoints(null)}
/>
)}
{showLinkBudget && (
<div className="absolute top-20 left-4 z-[1500]">
<LinkBudgetPanel
rxPoint={linkBudgetRxPoint}
onRequestMapClick={() => setActiveTool('rx-placement')}
onClose={() => {
setShowLinkBudget(false);
clearTool();
setLinkBudgetRxPoint(null);
}}
/>
</div>
)}
</div>
{/* Side panel */}
<div
className={`${
panelCollapsed ? 'hidden' : 'flex'
} flex-col bg-gray-50 dark:bg-dark-bg border-l border-gray-200 dark:border-dark-border
overflow-y-auto absolute sm:relative inset-0 sm:inset-auto z-[1001]`}
style={{ width: window.innerWidth >= 640 ? panelWidth : undefined }}
>
{/* Resize drag handle (desktop only) */}
<div
onMouseDown={handleDragStart}
className="hidden sm:block absolute left-0 top-0 bottom-0 w-1 cursor-col-resize z-10
hover:bg-blue-400/50 active:bg-blue-500/60 transition-colors"
/>
{/* Mobile drag handle + close */}
<div className="sm:hidden flex flex-col items-center pt-2 pb-1">
<div className="w-12 h-1 bg-gray-300 dark:bg-dark-border rounded-full mb-2" />
<button
onClick={() => setPanelCollapsed(true)}
className="text-gray-500 dark:text-dark-muted hover:text-gray-700 dark:hover:text-dark-text text-sm min-h-[44px]"
>
Close Panel
</button>
</div>
<div className="p-3 space-y-3 flex-1 overflow-y-auto">
{/* Site list */}
<SiteList onEditSite={handleEditSite} onAddSite={handleAddManual} />
{/* Quick frequency change */}
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm">
<BatchFrequencyChange />
</div>
{/* Coverage settings */}
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4 space-y-3">
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
Coverage Settings
</h3>
<div className="space-y-3">
<NumberInput
label="Radius"
value={settings.radius}
onChange={(v) => {
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)"
/>
<NumberInput
label="Resolution"
value={settings.resolution}
onChange={(v) =>
useCoverageStore.getState().updateSettings({ resolution: v })
}
min={50}
max={500}
step={50}
unit="m"
hint="Grid spacing — lower = more accurate but slower"
/>
<NumberInput
label="Min Signal"
value={settings.rsrpThreshold}
onChange={(v) =>
useCoverageStore.getState().updateSettings({ rsrpThreshold: v })
}
min={-140}
max={-50}
step={5}
unit="dBm"
hint="RSRP threshold — points below this are hidden"
/>
<NumberInput
label="Heatmap Opacity"
value={Math.round(settings.heatmapOpacity * 100)}
onChange={(v) =>
useCoverageStore.getState().updateSettings({ heatmapOpacity: v / 100 })
}
min={30}
max={100}
step={5}
unit="%"
hint="Transparency of the RF coverage overlay"
/>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-dark-text">
Smooth Rendering
</label>
<p className="text-xs text-gray-400 dark:text-dark-muted">
WebGL interpolation for smooth gradients
</p>
</div>
<button
onClick={() => setUseWebGLCoverage(!useWebGLCoverage)}
className={`relative w-11 h-6 rounded-full transition-colors ${
useWebGLCoverage ? 'bg-blue-500' : 'bg-gray-300 dark:bg-dark-border'
}`}
>
<span
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${
useWebGLCoverage ? 'translate-x-5' : ''
}`}
/>
</button>
</div>
{!useWebGLCoverage && (
<div>
<label className="text-sm font-medium text-gray-700 dark:text-dark-text">
Heatmap Quality
</label>
<select
value={settings.heatmapRadius}
onChange={(e) =>
useCoverageStore
.getState()
.updateSettings({
heatmapRadius: Number(e.target.value),
})
}
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"
>
<option value={200}>200m Fast</option>
<option value={400}>400m Balanced</option>
<option value={600}>600m Smooth</option>
<option value={800}>800m Wide</option>
</select>
<p className="mt-1 text-xs text-gray-400 dark:text-dark-muted">
Pixel radius per point larger = smoother, smaller = sharper
</p>
{settings.heatmapRadius >= 600 && settings.resolution > 200 && (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
Wide radius works best with fine resolution (200m or less). Current: {settings.resolution}m
</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,
use_water_reflection: preset.use_water_reflection,
use_vegetation: preset.use_vegetation,
});
}
}}
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 },
{ 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 }) => (
<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>
))}
{/* Season selector (only relevant when vegetation is enabled) */}
{settings.use_vegetation && (
<div className="mt-1.5 pl-5">
<label className="text-xs text-gray-500 dark:text-dark-muted">Season</label>
<select
value={settings.season || 'summer'}
onChange={(e) =>
useCoverageStore.getState().updateSettings({
season: e.target.value as 'summer' | 'winter' | 'spring' | 'autumn',
})
}
disabled={isCalculating}
className="w-full mt-0.5 px-2 py-1 text-xs bg-white dark:bg-dark-border border border-gray-300 dark:border-dark-border rounded text-gray-700 dark:text-dark-text disabled:opacity-50"
>
<option value="summer">Summer (full foliage)</option>
<option value="autumn">Autumn (70%)</option>
<option value="spring">Spring (60%)</option>
<option value="winter">Winter (30%)</option>
</select>
</div>
)}
{/* Atmospheric absorption toggle */}
<label
className={`flex items-center gap-2 cursor-pointer text-sm ${
isCalculating
? 'text-gray-400 dark:text-dark-muted cursor-not-allowed'
: 'text-gray-700 dark:text-dark-text'
}`}
>
<input
type="checkbox"
checked={settings.use_atmospheric ?? false}
onChange={(e) => {
useCoverageStore.getState().updateSettings({
use_atmospheric: e.target.checked,
preset: undefined,
});
}}
disabled={isCalculating}
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-dark-border accent-blue-600"
/>
Atmospheric Absorption
</label>
{settings.use_atmospheric && (
<div className="mt-1.5 pl-5 space-y-1.5">
<div>
<label className="text-xs text-gray-500 dark:text-dark-muted">Temperature</label>
<select
value={settings.temperature_c ?? 15}
onChange={(e) =>
useCoverageStore.getState().updateSettings({
temperature_c: Number(e.target.value),
})
}
disabled={isCalculating}
className="w-full mt-0.5 px-2 py-1 text-xs bg-white dark:bg-dark-border border border-gray-300 dark:border-dark-border rounded text-gray-700 dark:text-dark-text disabled:opacity-50"
>
<option value={-10}>-10°C (cold)</option>
<option value={0}>0°C (freezing)</option>
<option value={15}>15°C (mild)</option>
<option value={25}>25°C (warm)</option>
<option value={35}>35°C (hot)</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 dark:text-dark-muted">Humidity</label>
<select
value={settings.humidity_percent ?? 50}
onChange={(e) =>
useCoverageStore.getState().updateSettings({
humidity_percent: Number(e.target.value),
})
}
disabled={isCalculating}
className="w-full mt-0.5 px-2 py-1 text-xs bg-white dark:bg-dark-border border border-gray-300 dark:border-dark-border rounded text-gray-700 dark:text-dark-text disabled:opacity-50"
>
<option value={20}>20% (dry)</option>
<option value={50}>50% (normal)</option>
<option value={70}>70% (humid)</option>
<option value={90}>90% (very humid)</option>
</select>
</div>
</div>
)}
{/* Weather / Rain section */}
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-dark-border">
<p className="text-[10px] font-semibold text-gray-400 dark:text-dark-muted uppercase mb-1.5">Environment</p>
<div>
<label className="text-xs text-gray-500 dark:text-dark-muted">Rain Conditions</label>
<select
value={settings.rain_rate ?? 0}
onChange={(e) =>
useCoverageStore.getState().updateSettings({
rain_rate: Number(e.target.value),
preset: undefined,
})
}
disabled={isCalculating}
className="w-full mt-0.5 px-2 py-1 text-xs bg-white dark:bg-dark-border border border-gray-300 dark:border-dark-border rounded text-gray-700 dark:text-dark-text disabled:opacity-50"
>
<option value={0}>No Rain</option>
<option value={2.5}>Drizzle</option>
<option value={5}>Light Rain</option>
<option value={12.5}>Moderate Rain</option>
<option value={25}>Heavy Rain</option>
<option value={50}>Very Heavy Rain</option>
</select>
</div>
</div>
{/* Indoor penetration section */}
<div className="mt-1.5">
<label className="text-xs text-gray-500 dark:text-dark-muted">Indoor Coverage</label>
<select
value={settings.indoor_loss_type ?? 'none'}
onChange={(e) =>
useCoverageStore.getState().updateSettings({
indoor_loss_type: e.target.value,
preset: undefined,
})
}
disabled={isCalculating}
className="w-full mt-0.5 px-2 py-1 text-xs bg-white dark:bg-dark-border border border-gray-300 dark:border-dark-border rounded text-gray-700 dark:text-dark-text disabled:opacity-50"
>
<option value="none">Outdoor Only</option>
<option value="light">Light Building (wood, glass)</option>
<option value="medium">Medium Building (brick)</option>
<option value="heavy">Heavy Building (concrete)</option>
<option value="vehicle">Inside Vehicle</option>
</select>
</div>
{/* Fading margin */}
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-dark-border">
<NumberInput
label="Fading Margin"
value={settings.fading_margin ?? 0}
onChange={(v) => useCoverageStore.getState().updateSettings({ fading_margin: v })}
min={0}
max={20}
step={1}
unit="dB"
hint="Safety margin subtracted from signal"
/>
</div>
</div>
)}
</div>
<NumberInput
label="Terrain Opacity"
value={Math.round(terrainOpacity * 100)}
onChange={(v) => setTerrainOpacity(v / 100)}
min={10}
max={100}
step={5}
unit="%"
hint={!showTerrain ? 'Enable terrain overlay first' : undefined}
/>
</div>
</div>
{/* Map Tools */}
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4 space-y-3">
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
Map Tools
</h3>
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-dark-text">
<input
type="checkbox"
checked={showGrid}
onChange={(e) => setShowGrid(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 dark:border-dark-border accent-green-600"
/>
Coordinate Grid
</label>
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-dark-text">
<input
type="checkbox"
checked={activeTool === 'ruler'}
onChange={(e) => e.target.checked ? setActiveTool('ruler') : clearTool()}
className="w-4 h-4 rounded border-gray-300 dark:border-dark-border accent-orange-600"
/>
Distance Measurement
</label>
{activeTool === 'ruler' && (
<p className="text-xs text-gray-400 dark:text-dark-muted pl-6">
Click start and end points. Esc to cancel.
</p>
)}
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-dark-text">
<input
type="checkbox"
checked={showElevationInfo}
onChange={(e) => setShowElevationInfo(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 dark:border-dark-border accent-amber-600"
/>
Cursor Elevation
</label>
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-dark-text">
<input
type="checkbox"
checked={showElevationOverlay}
onChange={(e) => setShowElevationOverlay(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 dark:border-dark-border accent-amber-600"
/>
Elevation Colors
</label>
{showElevationOverlay && (
<div className="pl-6">
<NumberInput
label="Opacity"
value={Math.round(elevationOpacity * 100)}
onChange={(v) => setElevationOpacity(v / 100)}
min={10}
max={100}
step={10}
unit="%"
/>
</div>
)}
</div>
</div>
{/* Data Cache Status */}
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4 space-y-2">
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
Data Cache
</h3>
{cachedRegions.length > 0 ? (
<>
<div className="space-y-1">
{cachedRegions.filter((r) => r.downloaded || r.download_progress > 0).length > 0 ? (
cachedRegions
.filter((r) => r.downloaded || r.download_progress > 0)
.map((r) => (
<div key={r.id} className="flex items-center gap-2 text-xs">
<span
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
r.downloaded ? 'bg-emerald-500' : 'bg-amber-500'
}`}
/>
<span className="text-gray-700 dark:text-dark-text truncate">{r.name}</span>
{!r.downloaded && (
<span className="text-gray-400 dark:text-dark-muted ml-auto">
{Math.round(r.download_progress)}%
</span>
)}
</div>
))
) : (
<p className="text-xs text-gray-400 dark:text-dark-muted">No regions cached</p>
)}
</div>
{cacheStats && (
<p className="text-[11px] text-gray-400 dark:text-dark-muted">
{cacheStats.terrain_tiles} terrain tiles ({cacheStats.terrain_mb} MB)
</p>
)}
</>
) : (
<p className="text-xs text-gray-400 dark:text-dark-muted">Loading...</p>
)}
<div className="flex gap-2 pt-1">
<button
onClick={() => setShowWizard(true)}
className="text-xs px-2 py-1 bg-slate-100 dark:bg-dark-border text-gray-600 dark:text-dark-text rounded hover:bg-slate-200 dark:hover:bg-dark-muted transition-colors"
>
Download Regions
</button>
{isDesktop() && (
<button
onClick={async () => {
const desktop = getDesktopApi();
if (!desktop) return;
const result = await desktop.importRegionData();
if (result.success) {
addToast(result.message, 'success');
refreshCacheStatus();
} else {
if (result.message !== 'Cancelled') {
addToast(result.message, 'error');
}
}
}}
className="text-xs px-2 py-1 bg-slate-100 dark:bg-dark-border text-gray-600 dark:text-dark-text rounded hover:bg-slate-200 dark:hover:bg-dark-muted transition-colors"
>
Import Data
</button>
)}
</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}
/>
{/* Session history */}
<HistoryPanel />
{/* Export coverage data */}
<ExportPanel />
{/* Site import/export */}
<SiteImportExport />
{/* Projects save/load */}
<ProjectPanel />
</div>
</div>
</div>
{/* Site configuration modal */}
<SiteConfigModal
isOpen={modalState.isOpen}
mode={modalState.mode}
initialData={modalState.initialData}
onClose={handleCloseModal}
onSave={handleModalSave}
onSaveAndCalculate={handleModalSaveAndCalculate}
onDelete={modalState.mode === 'edit' ? handleModalDelete : undefined}
/>
{/* Keyboard delete confirmation dialog */}
{kbDeleteTarget && (
<ConfirmDialog
title="Delete Site?"
message={`Are you sure you want to delete "${kbDeleteTarget.name}"? This action can be undone for 10 seconds.`}
confirmLabel="Delete"
cancelLabel="Cancel"
danger
onConfirm={handleKbDeleteConfirmed}
onCancel={() => setKbDeleteTarget(null)}
/>
)}
<ToastContainer />
{/* First-run region download wizard (desktop only) */}
{showWizard && (
<RegionWizard onComplete={() => { setShowWizard(false); refreshCacheStatus(); }} />
)}
</div>
);
}