1378 lines
60 KiB
TypeScript
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>
|
|
);
|
|
}
|