@mytec: iteration1 implemented
This commit is contained in:
@@ -2,14 +2,17 @@ import { useEffect, useState, useCallback } from 'react';
|
|||||||
import type { Site } from '@/types/index.ts';
|
import type { Site } from '@/types/index.ts';
|
||||||
import { useSitesStore } from '@/store/sites.ts';
|
import { useSitesStore } from '@/store/sites.ts';
|
||||||
import { useCoverageStore } from '@/store/coverage.ts';
|
import { useCoverageStore } from '@/store/coverage.ts';
|
||||||
|
import '@/store/settings.ts'; // Side-effect: initializes theme on load
|
||||||
import { RFCalculator } from '@/rf/calculator.ts';
|
import { RFCalculator } from '@/rf/calculator.ts';
|
||||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||||
|
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts.ts';
|
||||||
import MapView from '@/components/map/Map.tsx';
|
import MapView from '@/components/map/Map.tsx';
|
||||||
import Heatmap from '@/components/map/Heatmap.tsx';
|
import Heatmap from '@/components/map/Heatmap.tsx';
|
||||||
import Legend from '@/components/map/Legend.tsx';
|
import Legend from '@/components/map/Legend.tsx';
|
||||||
import SiteList from '@/components/panels/SiteList.tsx';
|
import SiteList from '@/components/panels/SiteList.tsx';
|
||||||
import SiteForm from '@/components/panels/SiteForm.tsx';
|
import SiteForm from '@/components/panels/SiteForm.tsx';
|
||||||
import ToastContainer from '@/components/ui/Toast.tsx';
|
import ToastContainer from '@/components/ui/Toast.tsx';
|
||||||
|
import ThemeToggle from '@/components/ui/ThemeToggle.tsx';
|
||||||
import Button from '@/components/ui/Button.tsx';
|
import Button from '@/components/ui/Button.tsx';
|
||||||
|
|
||||||
const calculator = new RFCalculator();
|
const calculator = new RFCalculator();
|
||||||
@@ -35,6 +38,7 @@ export default function App() {
|
|||||||
lon: number;
|
lon: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [panelCollapsed, setPanelCollapsed] = useState(false);
|
const [panelCollapsed, setPanelCollapsed] = useState(false);
|
||||||
|
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||||
|
|
||||||
// Load sites from IndexedDB on mount
|
// Load sites from IndexedDB on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -70,19 +74,31 @@ export default function App() {
|
|||||||
setPendingLocation(null);
|
setPendingLocation(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Calculate coverage
|
// Calculate coverage (with better error handling)
|
||||||
const handleCalculate = useCallback(async () => {
|
const handleCalculate = useCallback(async () => {
|
||||||
if (sites.length === 0) {
|
const currentSites = useSitesStore.getState().sites;
|
||||||
addToast('Add at least one site first', 'error');
|
if (currentSites.length === 0) {
|
||||||
|
addToast('Add at least one site to calculate coverage', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSettings = useCoverageStore.getState().settings;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (currentSettings.radius > 100) {
|
||||||
|
addToast('Radius too large (max 100km)', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentSettings.resolution < 50) {
|
||||||
|
addToast('Resolution too fine (min 50m)', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsCalculating(true);
|
setIsCalculating(true);
|
||||||
try {
|
try {
|
||||||
// Calculate bounds from sites with radius buffer
|
const latitudes = currentSites.map((s) => s.lat);
|
||||||
const latitudes = sites.map((s) => s.lat);
|
const longitudes = currentSites.map((s) => s.lon);
|
||||||
const longitudes = sites.map((s) => s.lon);
|
const radiusDeg = currentSettings.radius / 111;
|
||||||
const radiusDeg = settings.radius / 111;
|
|
||||||
const avgLat =
|
const avgLat =
|
||||||
(Math.max(...latitudes) + Math.min(...latitudes)) / 2;
|
(Math.max(...latitudes) + Math.min(...latitudes)) / 2;
|
||||||
const lonRadiusDeg =
|
const lonRadiusDeg =
|
||||||
@@ -96,37 +112,68 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = await calculator.calculateCoverage(
|
const result = await calculator.calculateCoverage(
|
||||||
sites,
|
currentSites,
|
||||||
bounds,
|
bounds,
|
||||||
settings
|
currentSettings
|
||||||
);
|
);
|
||||||
|
|
||||||
setResult(result);
|
setResult(result);
|
||||||
|
|
||||||
|
if (result.points.length === 0) {
|
||||||
addToast(
|
addToast(
|
||||||
`Coverage calculated: ${result.totalPoints.toLocaleString()} points in ${(result.calculationTime / 1000).toFixed(2)}s`,
|
'No coverage points found. Try increasing radius or lowering threshold.',
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
addToast(
|
||||||
|
`Calculated ${result.totalPoints.toLocaleString()} points in ${(result.calculationTime / 1000).toFixed(1)}s`,
|
||||||
'success'
|
'success'
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(
|
console.error('Coverage calculation error:', err);
|
||||||
`Calculation error: ${err instanceof Error ? err.message : 'Unknown error'}`,
|
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
'error'
|
|
||||||
);
|
let userMessage = 'Calculation failed';
|
||||||
|
if (msg.includes('timeout')) {
|
||||||
|
userMessage = 'Calculation timeout. Try reducing radius or increasing resolution.';
|
||||||
|
} else if (msg.includes('worker') || msg.includes('Worker')) {
|
||||||
|
userMessage = 'Web Worker error. Please refresh the page.';
|
||||||
|
} else {
|
||||||
|
userMessage = `Calculation failed: ${msg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
addToast(userMessage, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setIsCalculating(false);
|
setIsCalculating(false);
|
||||||
}
|
}
|
||||||
}, [sites, settings, setIsCalculating, setResult, addToast]);
|
}, [setIsCalculating, setResult, addToast]);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useKeyboardShortcuts({
|
||||||
|
onCalculate: handleCalculate,
|
||||||
|
onCloseForm: handleCloseForm,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen flex flex-col bg-gray-100">
|
<div className="h-screen w-screen flex flex-col bg-gray-100 dark:bg-dark-bg">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="bg-slate-800 text-white px-4 py-2 flex items-center justify-between flex-shrink-0 z-10">
|
<header className="bg-slate-800 dark:bg-slate-900 text-white px-4 py-2 flex items-center justify-between flex-shrink-0 z-10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-base font-bold">RFCP</span>
|
<span className="text-base font-bold">RFCP</span>
|
||||||
<span className="text-xs text-slate-400 hidden sm:inline">
|
<span className="text-xs text-slate-400 hidden sm:inline">
|
||||||
RF Coverage Planner
|
RF Coverage Planner
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
|
<ThemeToggle />
|
||||||
|
<button
|
||||||
|
onClick={() => setShowShortcuts(!showShortcuts)}
|
||||||
|
className="text-slate-400 hover:text-white text-sm hidden sm:inline"
|
||||||
|
title="Keyboard shortcuts"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={isCalculating ? 'secondary' : 'primary'}
|
variant={isCalculating ? 'secondary' : 'primary'}
|
||||||
@@ -144,13 +191,54 @@ export default function App() {
|
|||||||
</Button>
|
</Button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPanelCollapsed(!panelCollapsed)}
|
onClick={() => setPanelCollapsed(!panelCollapsed)}
|
||||||
className="text-slate-400 hover:text-white text-sm sm:hidden"
|
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'}
|
{panelCollapsed ? 'Show' : 'Hide'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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>
|
||||||
|
<ul className="space-y-2 text-sm text-gray-600 dark:text-dark-muted">
|
||||||
|
<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>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">Ctrl+N</kbd>
|
||||||
|
</li>
|
||||||
|
<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>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>
|
||||||
|
<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 */}
|
{/* Main content */}
|
||||||
<div className="flex-1 flex overflow-hidden relative">
|
<div className="flex-1 flex overflow-hidden relative">
|
||||||
{/* Map */}
|
{/* Map */}
|
||||||
@@ -170,14 +258,15 @@ export default function App() {
|
|||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
panelCollapsed ? 'hidden' : 'flex'
|
panelCollapsed ? 'hidden' : 'flex'
|
||||||
} flex-col w-full sm:w-80 lg:w-96 bg-gray-50 border-l border-gray-200
|
} flex-col w-full sm:w-80 lg:w-96 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]`}
|
overflow-y-auto absolute sm:relative inset-0 sm:inset-auto z-[1001]`}
|
||||||
>
|
>
|
||||||
{/* Close button on mobile */}
|
{/* Mobile drag handle + close */}
|
||||||
<div className="sm:hidden p-2">
|
<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
|
<button
|
||||||
onClick={() => setPanelCollapsed(true)}
|
onClick={() => setPanelCollapsed(true)}
|
||||||
className="text-gray-500 hover:text-gray-700 text-sm"
|
className="text-gray-500 dark:text-dark-muted hover:text-gray-700 dark:hover:text-dark-text text-sm min-h-[44px]"
|
||||||
>
|
>
|
||||||
Close Panel
|
Close Panel
|
||||||
</button>
|
</button>
|
||||||
@@ -194,34 +283,36 @@ export default function App() {
|
|||||||
pendingLocation={pendingLocation}
|
pendingLocation={pendingLocation}
|
||||||
onClose={handleCloseForm}
|
onClose={handleCloseForm}
|
||||||
onClearPending={() => setPendingLocation(null)}
|
onClearPending={() => setPendingLocation(null)}
|
||||||
|
onCalculate={handleCalculate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Coverage settings */}
|
{/* Coverage settings */}
|
||||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm p-4 space-y-3">
|
<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">
|
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
|
||||||
Coverage Settings
|
Coverage Settings
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">
|
<label className="text-xs text-gray-500 dark:text-dark-muted">
|
||||||
Radius: {settings.radius} km
|
Radius: {settings.radius} km
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={1}
|
min={1}
|
||||||
max={20}
|
max={100}
|
||||||
|
step={5}
|
||||||
value={settings.radius}
|
value={settings.radius}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
useCoverageStore
|
useCoverageStore
|
||||||
.getState()
|
.getState()
|
||||||
.updateSettings({ radius: Number(e.target.value) })
|
.updateSettings({ radius: Number(e.target.value) })
|
||||||
}
|
}
|
||||||
className="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">
|
<label className="text-xs text-gray-500 dark:text-dark-muted">
|
||||||
Resolution: {settings.resolution}m
|
Resolution: {settings.resolution}m
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -235,11 +326,11 @@ export default function App() {
|
|||||||
.getState()
|
.getState()
|
||||||
.updateSettings({ resolution: Number(e.target.value) })
|
.updateSettings({ resolution: Number(e.target.value) })
|
||||||
}
|
}
|
||||||
className="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">
|
<label className="text-xs text-gray-500 dark:text-dark-muted">
|
||||||
Min Signal: {settings.rsrpThreshold} dBm
|
Min Signal: {settings.rsrpThreshold} dBm
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -255,7 +346,7 @@ export default function App() {
|
|||||||
rsrpThreshold: Number(e.target.value),
|
rsrpThreshold: Number(e.target.value),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,12 +47,12 @@ export default function Heatmap({ points, visible }: HeatmapProps) {
|
|||||||
max: 1.0,
|
max: 1.0,
|
||||||
minOpacity: 0.3,
|
minOpacity: 0.3,
|
||||||
gradient: {
|
gradient: {
|
||||||
0.0: '#ef4444', // red (weak)
|
0.0: '#0d47a1', // Dark Blue (very weak, -120 dBm)
|
||||||
0.2: '#f97316', // orange (poor)
|
0.2: '#00bcd4', // Cyan (weak, -110 dBm)
|
||||||
0.4: '#eab308', // yellow (fair)
|
0.4: '#4caf50', // Green (fair, -100 dBm)
|
||||||
0.6: '#84cc16', // lime (good)
|
0.6: '#ffeb3b', // Yellow (good, -85 dBm)
|
||||||
0.8: '#22c55e', // green (excellent)
|
0.8: '#ff9800', // Orange (strong, -70 dBm)
|
||||||
1.0: '#16a34a', // dark green (very strong)
|
1.0: '#f44336', // Red (excellent, > -70 dBm)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
import { RSRP_LEGEND } from '@/constants/rsrp-thresholds.ts';
|
import { RSRP_LEGEND } from '@/constants/rsrp-thresholds.ts';
|
||||||
import { useCoverageStore } from '@/store/coverage.ts';
|
import { useCoverageStore } from '@/store/coverage.ts';
|
||||||
|
import { useSitesStore } from '@/store/sites.ts';
|
||||||
|
|
||||||
export default function Legend() {
|
export default function Legend() {
|
||||||
const result = useCoverageStore((s) => s.result);
|
const result = useCoverageStore((s) => s.result);
|
||||||
const heatmapVisible = useCoverageStore((s) => s.heatmapVisible);
|
const heatmapVisible = useCoverageStore((s) => s.heatmapVisible);
|
||||||
const toggleHeatmap = useCoverageStore((s) => s.toggleHeatmap);
|
const toggleHeatmap = useCoverageStore((s) => s.toggleHeatmap);
|
||||||
|
const settings = useCoverageStore((s) => s.settings);
|
||||||
|
const sites = useSitesStore((s) => s.sites);
|
||||||
|
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
|
|
||||||
|
// Estimate coverage area: total points * resolution^2
|
||||||
|
const areaKm2 = (result.totalPoints * settings.resolution * settings.resolution) / 1e6;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-6 right-2 z-[1000] bg-white rounded-lg shadow-lg border border-gray-200 p-3 min-w-[160px]">
|
<div className="absolute bottom-6 right-2 z-[1000] bg-white dark:bg-dark-surface rounded-lg shadow-lg border border-gray-200 dark:border-dark-border p-3 min-w-[170px]">
|
||||||
{/* Header with toggle */}
|
{/* Header with toggle */}
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 className="text-xs font-semibold text-gray-700">Signal (RSRP)</h3>
|
<h3 className="text-xs font-semibold text-gray-700 dark:text-dark-text">Signal (RSRP)</h3>
|
||||||
<button
|
<button
|
||||||
onClick={toggleHeatmap}
|
onClick={toggleHeatmap}
|
||||||
className={`w-8 h-4 rounded-full transition-colors relative
|
className={`w-8 h-4 rounded-full transition-colors relative
|
||||||
${heatmapVisible ? 'bg-blue-500' : 'bg-gray-300'}`}
|
${heatmapVisible ? 'bg-blue-500' : 'bg-gray-300 dark:bg-dark-border'}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`absolute top-0.5 w-3 h-3 rounded-full bg-white shadow transition-transform
|
className={`absolute top-0.5 w-3 h-3 rounded-full bg-white shadow transition-transform
|
||||||
@@ -33,7 +39,7 @@ export default function Legend() {
|
|||||||
className="w-3 h-3 rounded-sm flex-shrink-0"
|
className="w-3 h-3 rounded-sm flex-shrink-0"
|
||||||
style={{ backgroundColor: item.color }}
|
style={{ backgroundColor: item.color }}
|
||||||
/>
|
/>
|
||||||
<span className="text-[10px] text-gray-600">
|
<span className="text-[10px] text-gray-600 dark:text-gray-400">
|
||||||
{item.label}: {item.range}
|
{item.label}: {item.range}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,11 +47,11 @@ export default function Legend() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="mt-2 pt-2 border-t border-gray-100 text-[10px] text-gray-400 space-y-0.5">
|
<div className="mt-2 pt-2 border-t border-gray-100 dark:border-dark-border text-[10px] text-gray-400 dark:text-dark-muted space-y-0.5">
|
||||||
<div>Points: {result.totalPoints.toLocaleString()}</div>
|
<div>Points: {result.totalPoints.toLocaleString()}</div>
|
||||||
<div>
|
<div>Time: {(result.calculationTime / 1000).toFixed(2)}s</div>
|
||||||
Time: {(result.calculationTime / 1000).toFixed(2)}s
|
<div>Area: ~{areaKm2.toFixed(1)} km²</div>
|
||||||
</div>
|
<div>Sites: {sites.length}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { MapContainer, TileLayer, useMapEvents } from 'react-leaflet';
|
import { useRef, useCallback } from 'react';
|
||||||
|
import { MapContainer, TileLayer, useMapEvents, useMap } from 'react-leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import type { Map as LeafletMap } from 'leaflet';
|
||||||
import type { Site } from '@/types/index.ts';
|
import type { Site } from '@/types/index.ts';
|
||||||
import { useSitesStore } from '@/store/sites.ts';
|
import { useSitesStore } from '@/store/sites.ts';
|
||||||
import SiteMarker from './SiteMarker.tsx';
|
import SiteMarker from './SiteMarker.tsx';
|
||||||
@@ -28,16 +30,38 @@ function MapClickHandler({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inner component that exposes the map instance via ref callback
|
||||||
|
*/
|
||||||
|
function MapRefSetter({ mapRef }: { mapRef: React.MutableRefObject<LeafletMap | null> }) {
|
||||||
|
const map = useMap();
|
||||||
|
mapRef.current = map;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function MapView({ onMapClick, onEditSite, children }: MapViewProps) {
|
export default function MapView({ onMapClick, onEditSite, children }: MapViewProps) {
|
||||||
const sites = useSitesStore((s) => s.sites);
|
const sites = useSitesStore((s) => s.sites);
|
||||||
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
|
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
|
||||||
|
const mapRef = useRef<LeafletMap | null>(null);
|
||||||
|
|
||||||
|
const handleFitToSites = useCallback(() => {
|
||||||
|
if (sites.length === 0 || !mapRef.current) return;
|
||||||
|
const bounds = sites.map((site) => [site.lat, site.lon] as [number, number]);
|
||||||
|
mapRef.current.fitBounds(bounds, { padding: [50, 50] });
|
||||||
|
}, [sites]);
|
||||||
|
|
||||||
|
const handleResetView = useCallback(() => {
|
||||||
|
mapRef.current?.setView([48.4, 35.0], 7);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={[48.4, 35.0]}
|
center={[48.4, 35.0]}
|
||||||
zoom={7}
|
zoom={7}
|
||||||
className={`w-full h-full ${isPlacingMode ? 'cursor-crosshair' : ''}`}
|
className={`w-full h-full ${isPlacingMode ? 'cursor-crosshair' : ''}`}
|
||||||
>
|
>
|
||||||
|
<MapRefSetter mapRef={mapRef} />
|
||||||
<TileLayer
|
<TileLayer
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
@@ -50,5 +74,30 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
|
|||||||
))}
|
))}
|
||||||
{children}
|
{children}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
|
||||||
|
{/* Map control buttons */}
|
||||||
|
<div className="absolute top-4 right-4 z-[1000] flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleFitToSites}
|
||||||
|
disabled={sites.length === 0}
|
||||||
|
className="bg-white dark:bg-dark-surface shadow-lg rounded px-3 py-2 text-sm
|
||||||
|
hover:bg-gray-50 dark:hover:bg-dark-border transition-colors
|
||||||
|
disabled:opacity-40 disabled:cursor-not-allowed
|
||||||
|
text-gray-700 dark:text-dark-text min-h-[36px]"
|
||||||
|
title="Fit view to all sites"
|
||||||
|
>
|
||||||
|
Fit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleResetView}
|
||||||
|
className="bg-white dark:bg-dark-surface shadow-lg rounded px-3 py-2 text-sm
|
||||||
|
hover:bg-gray-50 dark:hover:bg-dark-border transition-colors
|
||||||
|
text-gray-700 dark:text-dark-text min-h-[36px]"
|
||||||
|
title="Reset to Ukraine view"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function FrequencySelector({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-gray-700">
|
<label className="text-sm font-medium text-gray-700 dark:text-dark-text">
|
||||||
Operating Frequency
|
Operating Frequency
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -38,17 +38,17 @@ export default function FrequencySelector({
|
|||||||
key={freq}
|
key={freq}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(freq)}
|
onClick={() => onChange(freq)}
|
||||||
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors
|
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors min-h-[32px]
|
||||||
${
|
${
|
||||||
value === freq
|
value === freq
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-blue-600 text-white dark:bg-blue-500'
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-dark-border dark:text-dark-text dark:hover:bg-dark-muted'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{freq}
|
{freq}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<span className="self-center text-xs text-gray-400">MHz</span>
|
<span className="self-center text-xs text-gray-400 dark:text-dark-muted">MHz</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Custom input */}
|
{/* Custom input */}
|
||||||
@@ -59,7 +59,7 @@ export default function FrequencySelector({
|
|||||||
value={customInput}
|
value={customInput}
|
||||||
onChange={(e) => setCustomInput(e.target.value)}
|
onChange={(e) => setCustomInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleCustomSubmit()}
|
onKeyDown={(e) => e.key === 'Enter' && handleCustomSubmit()}
|
||||||
className="flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm
|
className="flex-1 px-3 py-1.5 border border-gray-300 dark:border-dark-border dark:bg-dark-bg dark:text-dark-text rounded-md text-sm
|
||||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
min={1}
|
min={1}
|
||||||
max={100000}
|
max={100000}
|
||||||
@@ -67,32 +67,32 @@ export default function FrequencySelector({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCustomSubmit}
|
onClick={handleCustomSubmit}
|
||||||
className="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 rounded-md text-sm text-gray-700"
|
className="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 dark:bg-dark-border dark:hover:bg-dark-muted dark:text-dark-text rounded-md text-sm text-gray-700 min-h-[32px]"
|
||||||
>
|
>
|
||||||
Set
|
Set
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Current frequency display */}
|
{/* Current frequency display */}
|
||||||
<div className="text-sm text-gray-600 font-medium">
|
<div className="text-sm text-gray-600 dark:text-dark-muted font-medium">
|
||||||
Current: {value} MHz
|
Current: {value} MHz
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Band info */}
|
{/* Band info */}
|
||||||
{bandInfo ? (
|
{bandInfo ? (
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-2 text-xs space-y-0.5">
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-2 text-xs space-y-0.5">
|
||||||
<div className="font-semibold text-blue-700">
|
<div className="font-semibold text-blue-700 dark:text-blue-300">
|
||||||
{bandInfo.name} ({bandInfo.range})
|
{bandInfo.name} ({bandInfo.range})
|
||||||
</div>
|
</div>
|
||||||
<div className="text-blue-600">
|
<div className="text-blue-600 dark:text-blue-400">
|
||||||
λ = {getWavelength(value)} · {bandInfo.characteristics.range} range ·{' '}
|
λ = {getWavelength(value)} · {bandInfo.characteristics.range} range ·{' '}
|
||||||
{bandInfo.characteristics.penetration} penetration
|
{bandInfo.characteristics.penetration} penetration
|
||||||
</div>
|
</div>
|
||||||
<div className="text-blue-500">{bandInfo.characteristics.typical}</div>
|
<div className="text-blue-500 dark:text-blue-400">{bandInfo.characteristics.typical}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-2 text-xs">
|
<div className="bg-gray-50 dark:bg-dark-bg border border-gray-200 dark:border-dark-border rounded-md p-2 text-xs">
|
||||||
<div className="text-gray-600">
|
<div className="text-gray-600 dark:text-dark-muted">
|
||||||
Custom frequency: {value} MHz · λ = {getWavelength(value)}
|
Custom frequency: {value} MHz · λ = {getWavelength(value)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,12 +12,36 @@ interface SiteFormProps {
|
|||||||
pendingLocation?: { lat: number; lon: number } | null;
|
pendingLocation?: { lat: number; lon: number } | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onClearPending?: () => void;
|
onClearPending?: () => void;
|
||||||
|
onCalculate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TEMPLATES = {
|
const TEMPLATES = {
|
||||||
limesdr: { name: 'LimeSDR', power: 20, gain: 3, frequency: 1800, height: 5 },
|
limesdr: {
|
||||||
lowBBU: { name: 'Low BBU', power: 37, gain: 15, frequency: 1800, height: 25 },
|
name: 'LimeSDR Mini',
|
||||||
highBBU: { name: 'High BBU', power: 46, gain: 18, frequency: 1800, height: 35 },
|
power: 20,
|
||||||
|
gain: 2,
|
||||||
|
frequency: 1800,
|
||||||
|
height: 10,
|
||||||
|
antennaType: 'omni' as const,
|
||||||
|
},
|
||||||
|
lowBBU: {
|
||||||
|
name: 'Low Power BBU',
|
||||||
|
power: 40,
|
||||||
|
gain: 8,
|
||||||
|
frequency: 1800,
|
||||||
|
height: 20,
|
||||||
|
antennaType: 'omni' as const,
|
||||||
|
},
|
||||||
|
highBBU: {
|
||||||
|
name: 'High Power BBU',
|
||||||
|
power: 43,
|
||||||
|
gain: 15,
|
||||||
|
frequency: 1800,
|
||||||
|
height: 30,
|
||||||
|
antennaType: 'sector' as const,
|
||||||
|
azimuth: 0,
|
||||||
|
beamwidth: 65,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SiteForm({
|
export default function SiteForm({
|
||||||
@@ -25,9 +49,11 @@ export default function SiteForm({
|
|||||||
pendingLocation,
|
pendingLocation,
|
||||||
onClose,
|
onClose,
|
||||||
onClearPending,
|
onClearPending,
|
||||||
|
onCalculate,
|
||||||
}: SiteFormProps) {
|
}: SiteFormProps) {
|
||||||
const addSite = useSitesStore((s) => s.addSite);
|
const addSite = useSitesStore((s) => s.addSite);
|
||||||
const updateSite = useSitesStore((s) => s.updateSite);
|
const updateSite = useSitesStore((s) => s.updateSite);
|
||||||
|
const deleteSite = useSitesStore((s) => s.deleteSite);
|
||||||
const addToast = useToastStore((s) => s.addToast);
|
const addToast = useToastStore((s) => s.addToast);
|
||||||
|
|
||||||
const [name, setName] = useState(editSite?.name ?? 'Station-1');
|
const [name, setName] = useState(editSite?.name ?? 'Station-1');
|
||||||
@@ -58,9 +84,13 @@ export default function SiteForm({
|
|||||||
setGain(t.gain);
|
setGain(t.gain);
|
||||||
setFrequency(t.frequency);
|
setFrequency(t.frequency);
|
||||||
setHeight(t.height);
|
setHeight(t.height);
|
||||||
|
setAntennaType(t.antennaType);
|
||||||
|
if ('azimuth' in t) setAzimuth(t.azimuth);
|
||||||
|
if ('beamwidth' in t) setBeamwidth(t.beamwidth);
|
||||||
|
addToast(`Applied: ${t.name}`, 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSave = async () => {
|
||||||
if (editSite) {
|
if (editSite) {
|
||||||
await updateSite(editSite.id, {
|
await updateSite(editSite.id, {
|
||||||
name,
|
name,
|
||||||
@@ -98,15 +128,33 @@ export default function SiteForm({
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveAndCalculate = async () => {
|
||||||
|
await handleSave();
|
||||||
|
// Trigger calculation after save
|
||||||
|
if (onCalculate) {
|
||||||
|
// Small delay to ensure store is updated
|
||||||
|
setTimeout(() => onCalculate(), 50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (editSite) {
|
||||||
|
await deleteSite(editSite.id);
|
||||||
|
addToast(`"${editSite.name}" deleted`, 'info');
|
||||||
|
onClearPending?.();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-y-auto max-h-[calc(100vh-120px)]">
|
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-lg overflow-y-auto max-h-[calc(100vh-120px)]">
|
||||||
<div className="sticky top-0 bg-white border-b border-gray-200 px-4 py-3 flex items-center justify-between">
|
<div className="sticky top-0 bg-white dark:bg-dark-surface border-b border-gray-200 dark:border-dark-border px-4 py-3 flex items-center justify-between z-10">
|
||||||
<h2 className="text-sm font-semibold text-gray-800">
|
<h2 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
|
||||||
{editSite ? 'Edit Site' : 'New Site Configuration'}
|
{editSite ? 'Edit Site' : 'New Site Configuration'}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 hover:text-gray-600 text-lg"
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-dark-text text-lg min-w-[32px] min-h-[32px] flex items-center justify-center"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -123,7 +171,7 @@ export default function SiteForm({
|
|||||||
|
|
||||||
{/* Coordinates */}
|
{/* Coordinates */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-sm font-medium text-gray-700">Coordinates</label>
|
<label className="text-sm font-medium text-gray-700 dark:text-dark-text">Coordinates</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<input
|
<input
|
||||||
@@ -131,7 +179,7 @@ export default function SiteForm({
|
|||||||
step="0.0001"
|
step="0.0001"
|
||||||
value={lat}
|
value={lat}
|
||||||
onChange={(e) => setLat(Number(e.target.value))}
|
onChange={(e) => setLat(Number(e.target.value))}
|
||||||
className="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm
|
className="w-full px-3 py-1.5 border border-gray-300 dark:border-dark-border dark:bg-dark-bg dark:text-dark-text rounded-md text-sm
|
||||||
focus:outline-none focus:ring-2 focus:ring-blue-500"
|
focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="Lat"
|
placeholder="Lat"
|
||||||
/>
|
/>
|
||||||
@@ -142,7 +190,7 @@ export default function SiteForm({
|
|||||||
step="0.0001"
|
step="0.0001"
|
||||||
value={lon}
|
value={lon}
|
||||||
onChange={(e) => setLon(Number(e.target.value))}
|
onChange={(e) => setLon(Number(e.target.value))}
|
||||||
className="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm
|
className="w-full px-3 py-1.5 border border-gray-300 dark:border-dark-border dark:bg-dark-bg dark:text-dark-text rounded-md text-sm
|
||||||
focus:outline-none focus:ring-2 focus:ring-blue-500"
|
focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="Lon"
|
placeholder="Lon"
|
||||||
/>
|
/>
|
||||||
@@ -151,8 +199,8 @@ export default function SiteForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
<div className="border-t border-gray-200 pt-2">
|
<div className="border-t border-gray-200 dark:border-dark-border pt-2">
|
||||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
<span className="text-xs font-semibold text-gray-500 dark:text-dark-muted uppercase tracking-wide">
|
||||||
RF Parameters
|
RF Parameters
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,8 +231,8 @@ export default function SiteForm({
|
|||||||
<FrequencySelector value={frequency} onChange={setFrequency} />
|
<FrequencySelector value={frequency} onChange={setFrequency} />
|
||||||
|
|
||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
<div className="border-t border-gray-200 pt-2">
|
<div className="border-t border-gray-200 dark:border-dark-border pt-2">
|
||||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
<span className="text-xs font-semibold text-gray-500 dark:text-dark-muted uppercase tracking-wide">
|
||||||
Physical Parameters
|
Physical Parameters
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,11 +250,11 @@ export default function SiteForm({
|
|||||||
|
|
||||||
{/* Antenna type */}
|
{/* Antenna type */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-gray-700">
|
<label className="text-sm font-medium text-gray-700 dark:text-dark-text">
|
||||||
Antenna Type
|
Antenna Type
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer min-h-[44px]">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="antennaType"
|
name="antennaType"
|
||||||
@@ -214,9 +262,9 @@ export default function SiteForm({
|
|||||||
onChange={() => setAntennaType('omni')}
|
onChange={() => setAntennaType('omni')}
|
||||||
className="accent-blue-600"
|
className="accent-blue-600"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">Omni</span>
|
<span className="text-sm dark:text-dark-text">Omni</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer min-h-[44px]">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="antennaType"
|
name="antennaType"
|
||||||
@@ -224,14 +272,14 @@ export default function SiteForm({
|
|||||||
onChange={() => setAntennaType('sector')}
|
onChange={() => setAntennaType('sector')}
|
||||||
className="accent-blue-600"
|
className="accent-blue-600"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">Sector</span>
|
<span className="text-sm dark:text-dark-text">Sector</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sector parameters - collapsible */}
|
{/* Sector parameters - collapsible */}
|
||||||
{antennaType === 'sector' && (
|
{antennaType === 'sector' && (
|
||||||
<div className="bg-gray-50 rounded-md p-3 space-y-3 border border-gray-200">
|
<div className="bg-gray-50 dark:bg-dark-bg rounded-md p-3 space-y-3 border border-gray-200 dark:border-dark-border">
|
||||||
<Slider
|
<Slider
|
||||||
label="Azimuth (degrees)"
|
label="Azimuth (degrees)"
|
||||||
value={azimuth}
|
value={azimuth}
|
||||||
@@ -252,15 +300,15 @@ export default function SiteForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
<div className="border-t border-gray-200 pt-2">
|
<div className="border-t border-gray-200 dark:border-dark-border pt-2">
|
||||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
<span className="text-xs font-semibold text-gray-500 dark:text-dark-muted uppercase tracking-wide">
|
||||||
Notes
|
Notes
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-sm font-medium text-gray-700">
|
<label className="text-sm font-medium text-gray-700 dark:text-dark-text">
|
||||||
Equipment / Notes (optional)
|
Equipment / Notes (optional)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -268,55 +316,61 @@ export default function SiteForm({
|
|||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
placeholder="e.g., ZTE B8200 BBU + custom omni antenna"
|
placeholder="e.g., ZTE B8200 BBU + custom omni antenna"
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
|
className="w-full px-3 py-2 border border-gray-300 dark:border-dark-border dark:bg-dark-bg dark:text-dark-text rounded-md text-sm
|
||||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||||
resize-none"
|
resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Templates */}
|
{/* Quick Templates */}
|
||||||
{!editSite && (
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
<label className="text-xs font-semibold text-gray-500 dark:text-dark-muted uppercase tracking-wide">
|
||||||
Quick Templates
|
Quick Templates
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => applyTemplate('limesdr')}
|
onClick={() => applyTemplate('limesdr')}
|
||||||
className="px-2 py-1 bg-purple-100 hover:bg-purple-200 text-purple-700
|
className="px-3 py-1.5 bg-purple-100 hover:bg-purple-200 text-purple-700
|
||||||
rounded text-xs font-medium transition-colors"
|
dark:bg-purple-900/30 dark:hover:bg-purple-900/50 dark:text-purple-300
|
||||||
|
rounded text-xs font-medium transition-colors min-h-[32px]"
|
||||||
>
|
>
|
||||||
LimeSDR
|
LimeSDR
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => applyTemplate('lowBBU')}
|
onClick={() => applyTemplate('lowBBU')}
|
||||||
className="px-2 py-1 bg-green-100 hover:bg-green-200 text-green-700
|
className="px-3 py-1.5 bg-green-100 hover:bg-green-200 text-green-700
|
||||||
rounded text-xs font-medium transition-colors"
|
dark:bg-green-900/30 dark:hover:bg-green-900/50 dark:text-green-300
|
||||||
|
rounded text-xs font-medium transition-colors min-h-[32px]"
|
||||||
>
|
>
|
||||||
Low BBU
|
Low BBU
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => applyTemplate('highBBU')}
|
onClick={() => applyTemplate('highBBU')}
|
||||||
className="px-2 py-1 bg-orange-100 hover:bg-orange-200 text-orange-700
|
className="px-3 py-1.5 bg-orange-100 hover:bg-orange-200 text-orange-700
|
||||||
rounded text-xs font-medium transition-colors"
|
dark:bg-orange-900/30 dark:hover:bg-orange-900/50 dark:text-orange-300
|
||||||
|
rounded text-xs font-medium transition-colors min-h-[32px]"
|
||||||
>
|
>
|
||||||
High BBU
|
High BBU
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<Button onClick={handleSubmit} className="flex-1">
|
<Button onClick={handleSaveAndCalculate} className="flex-1">
|
||||||
{editSite ? 'Save Changes' : 'Add Site'}
|
Save & Calculate
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={onClose}>
|
<Button variant="secondary" onClick={handleSave}>
|
||||||
Cancel
|
Save Only
|
||||||
</Button>
|
</Button>
|
||||||
|
{editSite && (
|
||||||
|
<Button variant="danger" onClick={handleDelete}>
|
||||||
|
Del
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm">
|
||||||
<div className="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
|
<div className="px-4 py-3 border-b border-gray-200 dark:border-dark-border flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold text-gray-800">
|
<h2 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
|
||||||
Sites ({sites.length})
|
Sites ({sites.length})
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -43,31 +43,36 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sites.length === 0 ? (
|
{sites.length === 0 ? (
|
||||||
<div className="p-4 text-center text-sm text-gray-400">
|
<div className="p-4 text-center text-sm text-gray-400 dark:text-dark-muted">
|
||||||
No sites yet. Click on the map or use "+ Manual" to add one.
|
No sites yet. Click on the map or use "+ Manual" to add one.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-100 max-h-60 overflow-y-auto">
|
<div className="divide-y divide-gray-100 dark:divide-dark-border max-h-60 overflow-y-auto">
|
||||||
{sites.map((site) => (
|
{sites.map((site) => (
|
||||||
<div
|
<div
|
||||||
key={site.id}
|
key={site.id}
|
||||||
className={`px-4 py-2 flex items-center gap-3 cursor-pointer hover:bg-gray-50
|
className={`px-4 py-2.5 flex items-center gap-3 cursor-pointer
|
||||||
transition-colors ${selectedSiteId === site.id ? 'bg-blue-50' : ''}`}
|
hover:bg-gray-50 dark:hover:bg-dark-border/50 transition-colors
|
||||||
|
${selectedSiteId === site.id ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`}
|
||||||
onClick={() => selectSite(site.id)}
|
onClick={() => selectSite(site.id)}
|
||||||
>
|
>
|
||||||
{/* Color indicator */}
|
{/* Color indicator */}
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
className="w-4 h-4 rounded-full flex-shrink-0 border-2 border-white dark:border-dark-border shadow"
|
||||||
style={{ backgroundColor: site.color }}
|
style={{ backgroundColor: site.color }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium text-gray-800 truncate">
|
<div className="text-sm font-medium text-gray-800 dark:text-dark-text truncate">
|
||||||
{site.name}
|
{site.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-400">
|
<div className="text-xs text-gray-500 dark:text-dark-muted">
|
||||||
{site.frequency} MHz · {site.power} dBm · {site.antennaType}
|
{site.frequency} MHz · {site.power} dBm ·{' '}
|
||||||
|
{site.antennaType === 'omni'
|
||||||
|
? 'Omni'
|
||||||
|
: `Sector ${site.azimuth ?? 0}°`}
|
||||||
|
{' '}· {site.height}m
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -78,7 +83,7 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onEditSite(site);
|
onEditSite(site);
|
||||||
}}
|
}}
|
||||||
className="px-2 py-1 text-xs text-blue-600 hover:bg-blue-50 rounded"
|
className="px-2 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded min-w-[44px] min-h-[32px] flex items-center justify-center"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -87,7 +92,7 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDelete(site.id, site.name);
|
handleDelete(site.id, site.name);
|
||||||
}}
|
}}
|
||||||
className="px-2 py-1 text-xs text-red-600 hover:bg-red-50 rounded"
|
className="px-2 py-1 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded min-w-[32px] min-h-[32px] flex items-center justify-center"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -7,16 +7,16 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
primary: 'bg-blue-600 hover:bg-blue-700 text-white',
|
primary: 'bg-blue-600 hover:bg-blue-700 text-white dark:bg-blue-500 dark:hover:bg-blue-600',
|
||||||
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-800',
|
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-800 dark:bg-dark-border dark:hover:bg-dark-muted dark:text-dark-text',
|
||||||
danger: 'bg-red-600 hover:bg-red-700 text-white',
|
danger: 'bg-red-600 hover:bg-red-700 text-white dark:bg-red-500 dark:hover:bg-red-600',
|
||||||
ghost: 'bg-transparent hover:bg-gray-100 text-gray-700',
|
ghost: 'bg-transparent hover:bg-gray-100 text-gray-700 dark:hover:bg-dark-border dark:text-dark-text',
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
sm: 'px-2 py-1 text-xs',
|
sm: 'px-2 py-1 text-xs min-h-[32px]',
|
||||||
md: 'px-3 py-1.5 text-sm',
|
md: 'px-3 py-1.5 text-sm min-h-[36px]',
|
||||||
lg: 'px-4 py-2 text-base',
|
lg: 'px-4 py-2 text-base min-h-[44px]',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Button({
|
export default function Button({
|
||||||
@@ -29,7 +29,7 @@ export default function Button({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`inline-flex items-center justify-center rounded-md font-medium transition-colors
|
className={`inline-flex items-center justify-center rounded-md font-medium transition-colors
|
||||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 dark:focus:ring-offset-dark-bg
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
${variants[variant]} ${sizes[size]} ${className}`}
|
${variants[variant]} ${sizes[size]} ${className}`}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ export default function Input({ label, className = '', ...props }: InputProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{label && (
|
{label && (
|
||||||
<label className="text-sm font-medium text-gray-700">{label}</label>
|
<label className="text-sm font-medium text-gray-700 dark:text-dark-text">{label}</label>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
className={`w-full px-3 py-2 border border-gray-300 rounded-md text-sm
|
className={`w-full px-3 py-2 border border-gray-300 rounded-md text-sm
|
||||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||||
|
dark:bg-dark-bg dark:border-dark-border dark:text-dark-text
|
||||||
${className}`}
|
${className}`}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ export default function Slider({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-sm font-medium text-gray-700">{label}</label>
|
<label className="text-sm font-medium text-gray-700 dark:text-dark-text">{label}</label>
|
||||||
<span className="text-sm font-semibold text-blue-600">
|
<span className="text-sm font-semibold text-blue-600 dark:text-blue-400">
|
||||||
{value} {unit}
|
{value} {unit}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,14 +34,14 @@ export default function Slider({
|
|||||||
step={step}
|
step={step}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer
|
className="w-full h-2 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer
|
||||||
accent-blue-600"
|
accent-blue-600"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between text-xs text-gray-400">
|
<div className="flex justify-between text-xs text-gray-400 dark:text-dark-muted">
|
||||||
<span>{min}</span>
|
<span>{min}</span>
|
||||||
<span>{max}</span>
|
<span>{max}</span>
|
||||||
</div>
|
</div>
|
||||||
{hint && <p className="text-xs text-gray-400">{hint}</p>}
|
{hint && <p className="text-xs text-gray-400 dark:text-dark-muted">{hint}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
32
frontend/src/components/ui/ThemeToggle.tsx
Normal file
32
frontend/src/components/ui/ThemeToggle.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useSettingsStore } from '@/store/settings.ts';
|
||||||
|
|
||||||
|
const themes = [
|
||||||
|
{ key: 'light' as const, icon: '\u2600\uFE0F', label: 'Light' },
|
||||||
|
{ key: 'dark' as const, icon: '\uD83C\uDF19', label: 'Dark' },
|
||||||
|
{ key: 'system' as const, icon: '\uD83D\uDCBB', label: 'System' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ThemeToggle() {
|
||||||
|
const theme = useSettingsStore((s) => s.theme);
|
||||||
|
const setTheme = useSettingsStore((s) => s.setTheme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5 bg-slate-700 dark:bg-slate-800 rounded-lg p-0.5">
|
||||||
|
{themes.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setTheme(t.key)}
|
||||||
|
className={`px-2 py-1 rounded text-xs transition-all
|
||||||
|
${
|
||||||
|
theme === t.key
|
||||||
|
? 'bg-slate-500 dark:bg-slate-600 shadow-sm'
|
||||||
|
: 'hover:bg-slate-600 dark:hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
title={t.label}
|
||||||
|
>
|
||||||
|
{t.icon}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||||
|
|
||||||
interface ToastMessage {
|
interface ToastMessage {
|
||||||
id: number;
|
id: number;
|
||||||
text: string;
|
text: string;
|
||||||
type: 'success' | 'error' | 'info';
|
type: ToastType;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToastStore {
|
interface ToastStore {
|
||||||
toasts: ToastMessage[];
|
toasts: ToastMessage[];
|
||||||
addToast: (text: string, type?: 'success' | 'error' | 'info') => void;
|
addToast: (text: string, type?: ToastType) => void;
|
||||||
removeToast: (id: number) => void;
|
removeToast: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,10 +51,12 @@ function ToastItem({ toast }: { toast: ToastMessage }) {
|
|||||||
|
|
||||||
const bgColor =
|
const bgColor =
|
||||||
toast.type === 'success'
|
toast.type === 'success'
|
||||||
? 'bg-green-500'
|
? 'bg-green-600'
|
||||||
: toast.type === 'error'
|
: toast.type === 'error'
|
||||||
? 'bg-red-500'
|
? 'bg-red-600'
|
||||||
: 'bg-blue-500';
|
: toast.type === 'warning'
|
||||||
|
? 'bg-amber-500'
|
||||||
|
: 'bg-blue-600';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -18,23 +18,25 @@ export function getSignalQuality(rsrp: number): SignalQuality {
|
|||||||
return 'no-service';
|
return 'no-service';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New color scheme: cold = weak, warm = strong
|
||||||
export const SIGNAL_COLORS: Record<string, string> = {
|
export const SIGNAL_COLORS: Record<string, string> = {
|
||||||
excellent: '#22c55e',
|
excellent: '#f44336', // Red (very strong)
|
||||||
good: '#84cc16',
|
good: '#ff9800', // Orange (strong)
|
||||||
fair: '#eab308',
|
fair: '#ffeb3b', // Yellow (acceptable)
|
||||||
poor: '#f97316',
|
poor: '#4caf50', // Green (weak)
|
||||||
weak: '#ef4444',
|
weak: '#00bcd4', // Cyan (very weak)
|
||||||
'no-service': '#6b7280',
|
'no-service': '#0d47a1', // Dark Blue (no coverage)
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function getRSRPColor(rsrp: number): string {
|
export function getRSRPColor(rsrp: number): string {
|
||||||
return SIGNAL_COLORS[getSignalQuality(rsrp)] ?? '#6b7280';
|
return SIGNAL_COLORS[getSignalQuality(rsrp)] ?? '#0d47a1';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RSRP_LEGEND = [
|
export const RSRP_LEGEND = [
|
||||||
{ label: 'Excellent', range: '> -70 dBm', color: SIGNAL_COLORS.excellent, min: -70 },
|
{ label: 'Excellent', range: '> -70 dBm', color: '#f44336', description: 'Very strong signal', min: -70 },
|
||||||
{ label: 'Good', range: '-70 to -85 dBm', color: SIGNAL_COLORS.good, min: -85 },
|
{ label: 'Good', range: '-70 to -85 dBm', color: '#ff9800', description: 'Strong signal', min: -85 },
|
||||||
{ label: 'Fair', range: '-85 to -100 dBm', color: SIGNAL_COLORS.fair, min: -100 },
|
{ label: 'Fair', range: '-85 to -100 dBm', color: '#ffeb3b', description: 'Acceptable signal', min: -100 },
|
||||||
{ label: 'Poor', range: '-100 to -110 dBm', color: SIGNAL_COLORS.poor, min: -110 },
|
{ label: 'Poor', range: '-100 to -110 dBm', color: '#4caf50', description: 'Weak signal', min: -110 },
|
||||||
{ label: 'Weak', range: '-110 to -120 dBm', color: SIGNAL_COLORS.weak, min: -120 },
|
{ label: 'Weak', range: '-110 to -120 dBm', color: '#00bcd4', description: 'Very weak signal', min: -120 },
|
||||||
|
{ label: 'No Service', range: '< -120 dBm', color: '#0d47a1', description: 'No coverage', min: -140 },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
61
frontend/src/hooks/useKeyboardShortcuts.ts
Normal file
61
frontend/src/hooks/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useSitesStore } from '@/store/sites.ts';
|
||||||
|
import { useCoverageStore } from '@/store/coverage.ts';
|
||||||
|
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||||
|
|
||||||
|
interface ShortcutHandlers {
|
||||||
|
onCalculate: () => void;
|
||||||
|
onCloseForm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKeyboardShortcuts({ onCalculate, onCloseForm }: ShortcutHandlers) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Ignore if typing in input or textarea
|
||||||
|
if (
|
||||||
|
e.target instanceof HTMLInputElement ||
|
||||||
|
e.target instanceof HTMLTextAreaElement ||
|
||||||
|
e.target instanceof HTMLSelectElement
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMac = navigator.platform.toLowerCase().includes('mac');
|
||||||
|
const modKey = isMac ? e.metaKey : e.ctrlKey;
|
||||||
|
|
||||||
|
if (modKey) {
|
||||||
|
switch (e.key.toLowerCase()) {
|
||||||
|
case 'enter': // Ctrl/Cmd+Enter: Calculate coverage
|
||||||
|
e.preventDefault();
|
||||||
|
onCalculate();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'n': // Ctrl/Cmd+N: New site (enter placement mode)
|
||||||
|
e.preventDefault();
|
||||||
|
useSitesStore.getState().setPlacingMode(true);
|
||||||
|
useToastStore.getState().addToast('Click on map to place new site', 'info');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-modifier shortcuts
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Escape': // Escape: Cancel/close
|
||||||
|
useSitesStore.getState().selectSite(null);
|
||||||
|
useSitesStore.getState().setPlacingMode(false);
|
||||||
|
onCloseForm();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'h': // H: Toggle heatmap
|
||||||
|
case 'H':
|
||||||
|
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||||
|
useCoverageStore.getState().toggleHeatmap();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [onCalculate, onCloseForm]);
|
||||||
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-dark-bg: #1a1a2e;
|
||||||
|
--color-dark-surface: #16213e;
|
||||||
|
--color-dark-border: #0f3460;
|
||||||
|
--color-dark-text: #e0e0e0;
|
||||||
|
--color-dark-muted: #8b8fa3;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -26,3 +36,8 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode map tiles (invert brightness slightly) */
|
||||||
|
.dark .leaflet-tile-pane {
|
||||||
|
filter: brightness(0.8) contrast(1.1) saturate(0.8);
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const useCoverageStore = create<CoverageState>((set) => ({
|
|||||||
result: null,
|
result: null,
|
||||||
isCalculating: false,
|
isCalculating: false,
|
||||||
settings: {
|
settings: {
|
||||||
radius: 5,
|
radius: 10,
|
||||||
resolution: 200,
|
resolution: 200,
|
||||||
rsrpThreshold: -120,
|
rsrpThreshold: -120,
|
||||||
},
|
},
|
||||||
|
|||||||
59
frontend/src/store/settings.ts
Normal file
59
frontend/src/store/settings.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
interface SettingsState {
|
||||||
|
theme: Theme;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme: Theme) {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
|
||||||
|
if (theme === 'system') {
|
||||||
|
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
root.classList.toggle('dark', systemDark);
|
||||||
|
} else {
|
||||||
|
root.classList.toggle('dark', theme === 'dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettingsStore = create<SettingsState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
theme: 'system' as Theme,
|
||||||
|
setTheme: (theme: Theme) => {
|
||||||
|
set({ theme });
|
||||||
|
applyTheme(theme);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'rfcp-settings',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply theme on initial page load
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('rfcp-settings');
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const theme = parsed?.state?.theme ?? 'system';
|
||||||
|
applyTheme(theme);
|
||||||
|
} else {
|
||||||
|
applyTheme('system');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
applyTheme('system');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
|
const current = useSettingsStore.getState().theme;
|
||||||
|
if (current === 'system') {
|
||||||
|
applyTheme('system');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user