@mytec: iter10.1 ready for testing

This commit is contained in:
2026-01-30 16:34:46 +02:00
parent 55fd42b696
commit d0e827e350
5 changed files with 96 additions and 60 deletions

View File

@@ -20,6 +20,7 @@ import ToastContainer from '@/components/ui/Toast.tsx';
import ThemeToggle from '@/components/ui/ThemeToggle.tsx'; import ThemeToggle from '@/components/ui/ThemeToggle.tsx';
import Button from '@/components/ui/Button.tsx'; import Button from '@/components/ui/Button.tsx';
import NumberInput from '@/components/ui/NumberInput.tsx'; import NumberInput from '@/components/ui/NumberInput.tsx';
import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx';
const calculator = new RFCalculator(); const calculator = new RFCalculator();
@@ -57,6 +58,7 @@ export default function App() {
} | null>(null); } | null>(null);
const [panelCollapsed, setPanelCollapsed] = useState(false); const [panelCollapsed, setPanelCollapsed] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false); const [showShortcuts, setShowShortcuts] = useState(false);
const [kbDeleteTarget, setKbDeleteTarget] = useState<{ id: string; name: string } | null>(null);
// Load sites from IndexedDB on mount // Load sites from IndexedDB on mount
useEffect(() => { useEffect(() => {
@@ -92,6 +94,44 @@ export default function App() {
setPendingLocation(null); setPendingLocation(null);
}, []); }, []);
// Keyboard delete confirmation handler
const handleKbDeleteConfirmed = useCallback(async () => {
if (!kbDeleteTarget) return;
const { id, name } = kbDeleteTarget;
const siteData = sites.find((s) => s.id === id);
setKbDeleteTarget(null);
await useSitesStore.getState().deleteSite(id);
if (siteData) {
addToast(`"${name}" deleted`, 'info', {
duration: 10000,
action: {
label: 'Undo',
onClick: async () => {
await useSitesStore.getState().addSite({
name: siteData.name,
lat: siteData.lat,
lon: siteData.lon,
height: siteData.height,
power: siteData.power,
gain: siteData.gain,
frequency: siteData.frequency,
antennaType: siteData.antennaType,
azimuth: siteData.azimuth,
beamwidth: siteData.beamwidth,
color: siteData.color,
visible: siteData.visible,
notes: siteData.notes,
equipment: siteData.equipment,
});
addToast(`"${siteData.name}" restored`, 'success');
},
},
});
}
}, [kbDeleteTarget, sites, addToast]);
// Calculate coverage (with better error handling) // Calculate coverage (with better error handling)
const handleCalculate = useCallback(async () => { const handleCalculate = useCallback(async () => {
const currentSites = useSitesStore.getState().sites; const currentSites = useSitesStore.getState().sites;
@@ -186,6 +226,9 @@ export default function App() {
onCalculate: handleCalculate, onCalculate: handleCalculate,
onCloseForm: handleCloseForm, onCloseForm: handleCloseForm,
onShowShortcuts: useCallback(() => setShowShortcuts(true), []), onShowShortcuts: useCallback(() => setShowShortcuts(true), []),
onDeleteRequest: useCallback((id: string, name: string) => {
setKbDeleteTarget({ id, name });
}, []),
}); });
return ( return (
@@ -538,6 +581,19 @@ export default function App() {
</div> </div>
</div> </div>
{/* Keyboard delete confirmation dialog */}
{kbDeleteTarget && (
<ConfirmDialog
title="Delete Site?"
message={`Are you sure you want to delete "${kbDeleteTarget.name}"? This action can be undone for 10 seconds.`}
confirmLabel="Delete"
cancelLabel="Cancel"
danger
onConfirm={handleKbDeleteConfirmed}
onCancel={() => setKbDeleteTarget(null)}
/>
)}
<ToastContainer /> <ToastContainer />
</div> </div>
); );

View File

@@ -57,10 +57,16 @@ export default memo(function CoverageStats({ points, resolution }: CoverageStats
const totalArea = estimateAreaKm2(points.length, resolution); const totalArea = estimateAreaKm2(points.length, resolution);
const total = points.length; const total = points.length;
const rsrpValues = points.map((p) => p.rsrp); // Use reduce instead of Math.min/max spread — spread crashes on 65k+ elements
const minRSRP = Math.min(...rsrpValues); let minRSRP = Infinity;
const maxRSRP = Math.max(...rsrpValues); let maxRSRP = -Infinity;
const avgRSRP = rsrpValues.reduce((a, b) => a + b, 0) / total; let sumRSRP = 0;
for (const p of points) {
if (p.rsrp < minRSRP) minRSRP = p.rsrp;
if (p.rsrp > maxRSRP) maxRSRP = p.rsrp;
sumRSRP += p.rsrp;
}
const avgRSRP = sumRSRP / total;
// Unique sites contributing to coverage // Unique sites contributing to coverage
const uniqueSites = new Set(points.map((p) => p.siteId)).size; const uniqueSites = new Set(points.map((p) => p.siteId)).size;

View File

@@ -18,14 +18,14 @@ export function getSignalQuality(rsrp: number): SignalQuality {
return 'no-service'; return 'no-service';
} }
// New color scheme: cold = weak, warm = strong // Warm color scheme: dark = weak, bright = strong
export const SIGNAL_COLORS: Record<string, string> = { export const SIGNAL_COLORS: Record<string, string> = {
excellent: '#f44336', // Red (very strong) excellent: '#ffeb3b', // Yellow (excellent)
good: '#ff9800', // Orange (strong) good: '#ff9800', // Orange (strong)
fair: '#ffeb3b', // Yellow (acceptable) fair: '#ff4444', // Bright red (fair)
poor: '#4caf50', // Green (weak) poor: '#cc0000', // Red (weak)
weak: '#00bcd4', // Cyan (very weak) weak: '#8b0000', // Dark red (very weak)
'no-service': '#0d47a1', // Dark Blue (no coverage) 'no-service': '#3c1428', // Deep maroon (no coverage)
} as const; } as const;
export function getRSRPColor(rsrp: number): string { export function getRSRPColor(rsrp: number): string {
@@ -33,10 +33,10 @@ export function getRSRPColor(rsrp: number): string {
} }
export const RSRP_LEGEND = [ export const RSRP_LEGEND = [
{ label: 'Excellent', range: '> -70 dBm', color: '#f44336', description: 'Very strong signal', min: -70 }, { label: 'Excellent', range: '> -70 dBm', color: '#ffeb3b', description: 'Very strong signal', min: -70 },
{ label: 'Good', range: '-70 to -85 dBm', color: '#ff9800', description: 'Strong signal', min: -85 }, { label: 'Good', range: '-70 to -85 dBm', color: '#ff9800', description: 'Strong signal', min: -85 },
{ label: 'Fair', range: '-85 to -100 dBm', color: '#ffeb3b', description: 'Acceptable signal', min: -100 }, { label: 'Fair', range: '-85 to -100 dBm', color: '#ff4444', description: 'Acceptable signal', min: -100 },
{ label: 'Poor', range: '-100 to -110 dBm', color: '#4caf50', description: 'Weak signal', min: -110 }, { label: 'Poor', range: '-100 to -110 dBm', color: '#cc0000', description: 'Weak signal', min: -110 },
{ label: 'Weak', range: '-110 to -120 dBm', color: '#00bcd4', description: 'Very weak signal', min: -120 }, { label: 'Weak', range: '-110 to -120 dBm', color: '#8b0000', description: 'Very weak signal', min: -120 },
{ label: 'No Service', range: '< -120 dBm', color: '#0d47a1', description: 'No coverage', min: -140 }, { label: 'No Service', range: '< -120 dBm', color: '#3c1428', description: 'No coverage', min: -140 },
] as const; ] as const;

View File

@@ -8,6 +8,7 @@ interface ShortcutHandlers {
onCalculate: () => void; onCalculate: () => void;
onCloseForm: () => void; onCloseForm: () => void;
onShowShortcuts?: () => void; onShowShortcuts?: () => void;
onDeleteRequest?: (siteId: string, siteName: string) => void;
} }
function isInputActive(): boolean { function isInputActive(): boolean {
@@ -21,6 +22,7 @@ export function useKeyboardShortcuts({
onCalculate, onCalculate,
onCloseForm, onCloseForm,
onShowShortcuts, onShowShortcuts,
onDeleteRequest,
}: ShortcutHandlers) { }: ShortcutHandlers) {
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@@ -85,41 +87,15 @@ export function useKeyboardShortcuts({
case 'f': // F: Fit to coverage — dispatch custom event for Map.tsx to handle case 'f': // F: Fit to coverage — dispatch custom event for Map.tsx to handle
window.dispatchEvent(new CustomEvent('rfcp:fit-bounds')); window.dispatchEvent(new CustomEvent('rfcp:fit-bounds'));
return; return;
case 'delete': // Delete key: delete selected site with undo case 'delete': // Delete key: show confirmation dialog
case 'backspace': case 'backspace':
{ {
const selectedId = useSitesStore.getState().selectedSiteId; const selectedId = useSitesStore.getState().selectedSiteId;
if (selectedId) { if (selectedId && onDeleteRequest) {
const site = useSitesStore.getState().sites.find(s => s.id === selectedId); const site = useSitesStore.getState().sites.find(s => s.id === selectedId);
if (site) { if (site) {
// Snapshot for undo e.preventDefault();
const snapshot = { ...site }; onDeleteRequest(site.id, site.name);
useSitesStore.getState().deleteSite(selectedId);
useToastStore.getState().addToast(`"${site.name}" deleted`, 'info', {
duration: 10000,
action: {
label: 'Undo',
onClick: async () => {
await useSitesStore.getState().addSite({
name: snapshot.name,
lat: snapshot.lat,
lon: snapshot.lon,
height: snapshot.height,
power: snapshot.power,
gain: snapshot.gain,
frequency: snapshot.frequency,
antennaType: snapshot.antennaType,
azimuth: snapshot.azimuth,
beamwidth: snapshot.beamwidth,
color: snapshot.color,
visible: snapshot.visible,
notes: snapshot.notes,
equipment: snapshot.equipment,
});
useToastStore.getState().addToast(`"${snapshot.name}" restored`, 'success');
},
},
});
} }
} }
} }
@@ -142,5 +118,5 @@ export function useKeyboardShortcuts({
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [onCalculate, onCloseForm, onShowShortcuts]); }, [onCalculate, onCloseForm, onShowShortcuts, onDeleteRequest]);
} }

View File

@@ -1,10 +1,10 @@
/** /**
* RSRP → color mapping with smooth gradient interpolation. * RSRP → color mapping with smooth gradient interpolation.
* *
* Gradient stops are chosen to match standard RF planning tools: * Warm-only palette (no cyan/green):
* -130 dBm = deep blue (no service) * -130 dBm = dark maroon (no service)
* -90 dBm = green (fair) * -90 dBm = orange (fair)
* -50 dBm = red (excellent) * -50 dBm = bright red (excellent)
* *
* All functions are pure and allocation-free on the hot path * All functions are pure and allocation-free on the hot path
* (pre-built lookup table for fast per-pixel color resolution). * (pre-built lookup table for fast per-pixel color resolution).
@@ -18,16 +18,14 @@ interface GradientStop {
} }
const GRADIENT_STOPS: GradientStop[] = [ const GRADIENT_STOPS: GradientStop[] = [
{ value: 0.0, r: 26, g: 35, b: 126 }, // #1a237e — deep blue { value: 0.0, r: 60, g: 20, b: 40 }, // #3c1428 — deep maroon (no service)
{ value: 0.15, r: 13, g: 71, b: 161 }, // #0d47a1 { value: 0.15, r: 100, g: 20, b: 30 }, // #64141e — dark red-brown
{ value: 0.25, r: 33, g: 150, b: 243 }, // #2196f3 — blue { value: 0.30, r: 139, g: 0, b: 0 }, // #8b0000 — dark red (very weak)
{ value: 0.35, r: 0, g: 188, b: 212 }, // #00bcd4 — cyan { value: 0.45, r: 204, g: 0, b: 0 }, // #cc0000 — red (weak)
{ value: 0.45, r: 0, g: 137, b: 123 }, // #00897b — teal { value: 0.60, r: 255, g: 68, b: 68 }, // #ff4444 — bright red (fair)
{ value: 0.55, r: 76, g: 175, b: 80 }, // #4caf50 — green { value: 0.75, r: 255, g: 107, b: 53 }, // #ff6b35 — orange-red (good)
{ value: 0.65, r: 139, g: 195, b: 74 }, // #8bc34a — light green { value: 0.85, r: 255, g: 152, b: 0 }, // #ff9800 — orange (strong)
{ value: 0.75, r: 255, g: 235, b: 59 }, // #ffeb3b — yellow { value: 1.0, r: 255, g: 235, b: 59 }, // #ffeb3b — yellow (excellent)
{ value: 0.85, r: 255, g: 152, b: 0 }, // #ff9800 — orange
{ value: 1.0, r: 244, g: 67, b: 54 }, // #f44336 — red
]; ];
/** /**