@mytec: iteration1 implemented

This commit is contained in:
2026-01-30 08:23:29 +02:00
parent f59e63f181
commit 6bc4357a3c
17 changed files with 566 additions and 187 deletions

View File

@@ -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);
addToast(
`Coverage calculated: ${result.totalPoints.toLocaleString()} points in ${(result.calculationTime / 1000).toFixed(2)}s`, if (result.points.length === 0) {
'success' addToast(
); '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'
);
}
} 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>

View File

@@ -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)
}, },
}); });

View File

@@ -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>
); );

View File

@@ -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,27 +30,74 @@ 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 <>
center={[48.4, 35.0]} <MapContainer
zoom={7} center={[48.4, 35.0]}
className={`w-full h-full ${isPlacingMode ? 'cursor-crosshair' : ''}`} zoom={7}
> className={`w-full h-full ${isPlacingMode ? 'cursor-crosshair' : ''}`}
<TileLayer >
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' <MapRefSetter mapRef={mapRef} />
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" <TileLayer
/> attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
<MapClickHandler onMapClick={onMapClick} /> url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
{sites />
.filter((s) => s.visible) <MapClickHandler onMapClick={onMapClick} />
.map((site) => ( {sites
<SiteMarker key={site.id} site={site} onEdit={onEditSite} /> .filter((s) => s.visible)
))} .map((site) => (
{children} <SiteMarker key={site.id} site={site} onEdit={onEditSite} />
</MapContainer> ))}
{children}
</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>
</>
); );
} }

View File

@@ -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>

View File

@@ -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 dark:text-dark-muted uppercase tracking-wide">
<label className="text-xs font-semibold text-gray-500 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-3 py-1.5 bg-purple-100 hover:bg-purple-200 text-purple-700
className="px-2 py-1 bg-purple-100 hover:bg-purple-200 text-purple-700 dark:bg-purple-900/30 dark:hover:bg-purple-900/50 dark:text-purple-300
rounded text-xs font-medium transition-colors" 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 >
</button> Low BBU
<button </button>
type="button" <button
onClick={() => applyTemplate('highBBU')} type="button"
className="px-2 py-1 bg-orange-100 hover:bg-orange-200 text-orange-700 onClick={() => applyTemplate('highBBU')}
rounded text-xs font-medium transition-colors" className="px-3 py-1.5 bg-orange-100 hover:bg-orange-200 text-orange-700
> dark:bg-orange-900/30 dark:hover:bg-orange-900/50 dark:text-orange-300
High BBU rounded text-xs font-medium transition-colors min-h-[32px]"
</button> >
</div> High BBU
</button>
</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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}
/> />

View File

@@ -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>
); );
} }

View 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>
);
}

View File

@@ -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

View File

@@ -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;

View 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]);
}

View File

@@ -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);
}

View File

@@ -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,
}, },

View 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');
}
});
}