@mytec: iter9 ready for test

This commit is contained in:
2026-01-30 14:47:32 +02:00
parent 7fe5f7068c
commit b932607521
7 changed files with 629 additions and 143 deletions

View File

@@ -169,6 +169,7 @@ export default function App() {
useKeyboardShortcuts({
onCalculate: handleCalculate,
onCloseForm: handleCloseForm,
onShowShortcuts: useCallback(() => setShowShortcuts(true), []),
});
return (
@@ -227,24 +228,76 @@ export default function App() {
<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>
<div className="space-y-3 text-sm text-gray-600 dark:text-dark-muted">
{/* Coverage */}
<div>
<h4 className="text-xs font-semibold text-gray-400 dark:text-dark-muted uppercase mb-1">Coverage</h4>
<ul className="space-y-1">
<li className="flex justify-between gap-4">
<span>Calculate coverage</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">Ctrl+Enter</kbd>
</li>
<li className="flex justify-between gap-4">
<span>Clear coverage</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">Shift+C</kbd>
</li>
<li className="flex justify-between gap-4">
<span>Toggle heatmap</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">H</kbd>
</li>
</ul>
</div>
{/* Sites */}
<div>
<h4 className="text-xs font-semibold text-gray-400 dark:text-dark-muted uppercase mb-1">Sites</h4>
<ul className="space-y-1">
<li className="flex justify-between gap-4">
<span>New site (place mode)</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">Shift+S</kbd>
</li>
<li className="flex justify-between gap-4">
<span>Delete selected</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">Delete</kbd>
</li>
</ul>
</div>
{/* View */}
<div>
<h4 className="text-xs font-semibold text-gray-400 dark:text-dark-muted uppercase mb-1">View</h4>
<ul className="space-y-1">
<li className="flex justify-between gap-4">
<span>Toggle grid</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">G</kbd>
</li>
<li className="flex justify-between gap-4">
<span>Toggle terrain</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">T</kbd>
</li>
<li className="flex justify-between gap-4">
<span>Toggle ruler</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">R</kbd>
</li>
<li className="flex justify-between gap-4">
<span>Fit to coverage</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">F</kbd>
</li>
</ul>
</div>
{/* General */}
<div>
<h4 className="text-xs font-semibold text-gray-400 dark:text-dark-muted uppercase mb-1">General</h4>
<ul className="space-y-1">
<li className="flex justify-between gap-4">
<span>Cancel / Close</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">Esc</kbd>
</li>
<li className="flex justify-between gap-4">
<span>Show shortcuts</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">?</kbd>
</li>
</ul>
</div>
</div>
<button
onClick={() => setShowShortcuts(false)}
className="mt-4 w-full py-2 text-sm bg-gray-100 dark:bg-dark-border dark:text-dark-text rounded-md hover:bg-gray-200 dark:hover:bg-dark-muted transition-colors"

View File

@@ -11,10 +11,13 @@ export default function BatchEdit({ onBatchApplied }: BatchEditProps) {
const selectedSiteIds = useSitesStore((s) => s.selectedSiteIds);
const batchUpdateHeight = useSitesStore((s) => s.batchUpdateHeight);
const batchSetHeight = useSitesStore((s) => s.batchSetHeight);
const batchAdjustAzimuth = useSitesStore((s) => s.batchAdjustAzimuth);
const batchSetAzimuth = useSitesStore((s) => s.batchSetAzimuth);
const clearSelection = useSitesStore((s) => s.clearSelection);
const addToast = useToastStore((s) => s.addToast);
const [customHeight, setCustomHeight] = useState('');
const [customAzimuth, setCustomAzimuth] = useState('');
if (selectedSiteIds.length === 0) return null;
@@ -23,7 +26,7 @@ export default function BatchEdit({ onBatchApplied }: BatchEditProps) {
await batchUpdateHeight(delta);
onBatchApplied?.(ids);
addToast(
`Updated ${ids.length} site(s) by ${delta > 0 ? '+' : ''}${delta}m`,
`Updated ${ids.length} site(s) height by ${delta > 0 ? '+' : ''}${delta}m`,
'success'
);
};
@@ -41,6 +44,29 @@ export default function BatchEdit({ onBatchApplied }: BatchEditProps) {
setCustomHeight('');
};
const handleAdjustAzimuth = async (delta: number) => {
const ids = [...selectedSiteIds];
await batchAdjustAzimuth(delta);
onBatchApplied?.(ids);
addToast(
`Rotated ${ids.length} site(s) by ${delta > 0 ? '+' : ''}${delta}°`,
'success'
);
};
const handleSetAzimuth = async () => {
const az = parseInt(customAzimuth, 10);
if (isNaN(az) || az < 0 || az > 359) {
addToast('Azimuth must be between 0-359°', 'error');
return;
}
const ids = [...selectedSiteIds];
await batchSetAzimuth(az);
onBatchApplied?.(ids);
addToast(`Set ${ids.length} site(s) azimuth to ${az}°`, 'success');
setCustomAzimuth('');
};
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 space-y-3">
<div className="flex items-center justify-between">
@@ -99,6 +125,73 @@ export default function BatchEdit({ onBatchApplied }: BatchEditProps) {
</Button>
</div>
</div>
{/* Adjust azimuth */}
<div>
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5 block">
Adjust Azimuth:
</label>
<div className="flex gap-1 flex-wrap">
<Button size="sm" variant="secondary" onClick={() => handleAdjustAzimuth(-90)}>
-90°
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustAzimuth(-45)}>
-45°
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustAzimuth(-10)}>
-10°
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustAzimuth(10)}>
+10°
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustAzimuth(45)}>
+45°
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustAzimuth(90)}>
+90°
</Button>
</div>
</div>
{/* Set exact azimuth */}
<div>
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5 block">
Set Azimuth:
</label>
<div className="flex gap-2">
<input
type="number"
min={0}
max={359}
value={customAzimuth}
onChange={(e) => setCustomAzimuth(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSetAzimuth()}
placeholder="0-359°"
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"
/>
<Button
size="sm"
variant="secondary"
onClick={() => {
const ids = [...selectedSiteIds];
batchSetAzimuth(0).then(() => {
onBatchApplied?.(ids);
addToast(`Set ${ids.length} site(s) to North (0°)`, 'success');
});
}}
>
N 0°
</Button>
<Button
size="sm"
onClick={handleSetAzimuth}
disabled={!customAzimuth}
>
Apply
</Button>
</div>
</div>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { useSitesStore } from '@/store/sites.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
import Button from '@/components/ui/Button.tsx';
import Input from '@/components/ui/Input.tsx';
import Slider from '@/components/ui/Slider.tsx';
import NumberInput from '@/components/ui/NumberInput.tsx';
import FrequencySelector from './FrequencySelector.tsx';
interface SiteFormProps {
@@ -284,22 +284,24 @@ export default function SiteForm({
</div>
{/* Power */}
<Slider
label="Transmit Power (dBm)"
<NumberInput
label="Transmit Power"
value={power}
min={10}
max={50}
step={1}
unit="dBm"
hint="LimeSDR 20, BBU 43, RRU 46"
onChange={setPower}
/>
{/* Gain */}
<Slider
label="Antenna Gain (dBi)"
<NumberInput
label="Antenna Gain"
value={gain}
min={0}
max={25}
step={0.5}
unit="dBi"
hint="Omni 2-8, Sector 15-18, Parabolic 20-25"
onChange={setGain}
@@ -316,11 +318,12 @@ export default function SiteForm({
</div>
{/* Height */}
<Slider
label="Antenna Height (meters)"
<NumberInput
label="Antenna Height"
value={height}
min={1}
max={100}
step={1}
unit="m"
hint="Height from ground to antenna center"
onChange={setHeight}
@@ -358,19 +361,21 @@ export default function SiteForm({
{/* Sector parameters - collapsible */}
{antennaType === 'sector' && (
<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
label="Azimuth (degrees)"
<NumberInput
label="Azimuth"
value={azimuth}
min={0}
max={360}
max={359}
step={1}
unit="°"
onChange={setAzimuth}
/>
<Slider
label="Beamwidth (degrees)"
<NumberInput
label="Beamwidth"
value={beamwidth}
min={30}
max={120}
step={5}
unit="°"
onChange={setBeamwidth}
/>

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useMemo } from 'react';
import type { Site } from '@/types/index.ts';
import { useSitesStore } from '@/store/sites.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
@@ -10,6 +10,33 @@ interface SiteListProps {
onAddSite: () => void;
}
/** Group co-located sites (within ~10m = 0.0001°) */
function groupSites(sites: Site[]): { key: string; label: string; sites: Site[] }[] {
const groups = new Map<string, Site[]>();
const order: string[] = [];
for (const site of sites) {
// Round to 4 decimals (~11m precision)
const key = `${site.lat.toFixed(4)},${site.lon.toFixed(4)}`;
if (!groups.has(key)) {
groups.set(key, []);
order.push(key);
}
groups.get(key)!.push(site);
}
return order.map((key) => {
const g = groups.get(key)!;
// Extract base name from first site (strip sector suffixes)
const baseName = g[0].name.replace(/-(Alpha|Beta|Gamma|Delta|Epsilon|Zeta|S\d+|clone)$/i, '');
return {
key,
label: baseName,
sites: g,
};
});
}
export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
const sites = useSitesStore((s) => s.sites);
const deleteSite = useSitesStore((s) => s.deleteSite);
@@ -24,12 +51,10 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
const cloneSector = useSitesStore((s) => s.cloneSector);
const addToast = useToastStore((s) => s.addToast);
// Track recently batch-updated site IDs for flash animation
const [flashIds, setFlashIds] = useState<Set<string>>(new Set());
const triggerFlash = useCallback((ids: string[]) => {
setFlashIds(new Set(ids));
// Clear after animation completes
setTimeout(() => setFlashIds(new Set()), 700);
}, []);
@@ -40,12 +65,112 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
const allSelected = sites.length > 0 && selectedSiteIds.length === sites.length;
// Group co-located sites
const siteGroups = useMemo(() => groupSites(sites), [sites]);
const hasGroups = siteGroups.some((g) => g.sites.length > 1);
// Count unique locations
const locationCount = siteGroups.length;
const renderSiteRow = (site: Site, isGrouped: boolean) => {
const isBatchSelected = selectedSiteIds.includes(site.id);
const isFlashing = flashIds.has(site.id);
return (
<div
key={site.id}
className={`px-4 py-2 flex items-center gap-2.5 cursor-pointer
hover:bg-gray-50 dark:hover:bg-dark-border/50 transition-colors
${selectedSiteId === site.id ? 'bg-blue-50 dark:bg-blue-900/20' : ''}
${isBatchSelected && selectedSiteId !== site.id ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}
${isFlashing ? 'flash-update' : ''}
${isGrouped ? 'pl-8' : ''}`}
onClick={() => selectSite(site.id)}
>
{/* Batch checkbox */}
<input
type="checkbox"
checked={isBatchSelected}
onChange={() => toggleSiteSelection(site.id)}
onClick={(e) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-dark-border accent-blue-600 flex-shrink-0"
/>
{/* Color indicator */}
<div
className="w-4 h-4 rounded-full flex-shrink-0 border-2 border-white dark:border-dark-border shadow"
style={{ backgroundColor: site.color }}
/>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-800 dark:text-dark-text truncate flex items-center gap-1.5">
{site.name}
{/* Azimuth badge for grouped sectors */}
{isGrouped && site.antennaType === 'sector' && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
{site.azimuth ?? 0}°
</span>
)}
</div>
<div className="text-xs text-gray-500 dark:text-dark-muted">
{site.frequency} MHz · {site.power} dBm ·{' '}
{site.antennaType === 'omni'
? 'Omni'
: `Sector ${site.azimuth ?? 0}°`}
{' '}· {site.height}m
</div>
</div>
{/* Actions */}
<div className="flex gap-1 flex-shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
onEditSite(site);
}}
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"
title="Edit site"
>
Edit
</button>
<button
onClick={async (e) => {
e.stopPropagation();
await cloneSector(site.id);
addToast(`Added sector to "${site.name}" (+120°)`, 'success');
}}
className="px-2 py-1 text-xs text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded min-h-[32px] flex items-center justify-center"
title="Add sector at same location (+120° azimuth)"
>
+ Sector
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(site.id, site.name);
}}
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"
title="Delete site"
>
×
</button>
</div>
</div>
);
};
return (
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm">
{/* Header */}
<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 dark:text-dark-text">
Sites ({sites.length})
{hasGroups && (
<span className="font-normal text-gray-400 dark:text-dark-muted ml-1">
· {locationCount} location{locationCount !== 1 ? 's' : ''}
</span>
)}
</h2>
<div className="flex gap-2">
<Button
@@ -61,7 +186,7 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
</div>
</div>
{/* Select All row (only when sites exist) */}
{/* Select All row */}
{sites.length > 0 && (
<div className="px-4 py-1.5 border-b border-gray-100 dark:border-dark-border flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer text-xs text-gray-500 dark:text-dark-muted">
@@ -81,7 +206,7 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
</div>
)}
{/* Batch edit panel (appears when sites are selected) */}
{/* Batch edit panel */}
{selectedSiteIds.length > 0 && (
<div className="px-3 pt-3">
<BatchEdit onBatchApplied={triggerFlash} />
@@ -94,84 +219,27 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
No sites yet. Click on the map or use "+ Manual" to add one.
</div>
) : (
<div className="divide-y divide-gray-100 dark:divide-dark-border max-h-60 overflow-y-auto">
{sites.map((site) => {
const isBatchSelected = selectedSiteIds.includes(site.id);
const isFlashing = flashIds.has(site.id);
<div className="divide-y divide-gray-100 dark:divide-dark-border max-h-72 overflow-y-auto">
{siteGroups.map((group) => {
if (group.sites.length === 1) {
// Single site — render normally
return renderSiteRow(group.sites[0], false);
}
// Multi-sector group
return (
<div
key={site.id}
className={`px-4 py-2.5 flex items-center gap-2.5 cursor-pointer
hover:bg-gray-50 dark:hover:bg-dark-border/50 transition-colors
${selectedSiteId === site.id ? 'bg-blue-50 dark:bg-blue-900/20' : ''}
${isBatchSelected && selectedSiteId !== site.id ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}
${isFlashing ? 'flash-update' : ''}`}
onClick={() => selectSite(site.id)}
>
{/* Batch checkbox */}
<input
type="checkbox"
checked={isBatchSelected}
onChange={() => toggleSiteSelection(site.id)}
onClick={(e) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-dark-border accent-blue-600 flex-shrink-0"
/>
{/* Color indicator */}
<div
className="w-4 h-4 rounded-full flex-shrink-0 border-2 border-white dark:border-dark-border shadow"
style={{ backgroundColor: site.color }}
/>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-800 dark:text-dark-text truncate">
{site.name}
</div>
<div className="text-xs text-gray-500 dark:text-dark-muted">
{site.frequency} MHz · {site.power} dBm ·{' '}
{site.antennaType === 'omni'
? 'Omni'
: `Sector ${site.azimuth ?? 0}°`}
{' '}· {site.height}m
</div>
</div>
{/* Actions */}
<div className="flex gap-1 flex-shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
onEditSite(site);
}}
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"
title="Edit site"
>
Edit
</button>
<button
onClick={async (e) => {
e.stopPropagation();
await cloneSector(site.id);
addToast(`Added sector to "${site.name}" (+120°)`, 'success');
}}
className="px-2 py-1 text-xs text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded min-h-[32px] flex items-center justify-center"
title="Add sector at same location (+120° azimuth)"
>
+ Sector
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(site.id, site.name);
}}
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"
title="Delete site"
>
×
</button>
<div key={group.key}>
{/* Group header */}
<div className="px-4 py-1.5 bg-gray-50 dark:bg-dark-bg flex items-center gap-2">
<span className="text-xs font-semibold text-gray-500 dark:text-dark-muted">
{group.label}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-300 font-medium">
{group.sites.length} sectors
</span>
</div>
{/* Grouped site rows */}
{group.sites.map((site) => renderSiteRow(site, true))}
</div>
);
})}

View File

@@ -0,0 +1,147 @@
import { useCallback, useRef } from 'react';
interface NumberInputProps {
label: string;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
unit?: string;
hint?: string;
showSlider?: boolean;
}
export default function NumberInput({
label,
value,
onChange,
min = 0,
max = 100,
step = 1,
unit = '',
hint,
showSlider = true,
}: NumberInputProps) {
const holdTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const clamp = useCallback(
(v: number) => Math.max(min, Math.min(max, v)),
[min, max]
);
const increment = useCallback(() => {
onChange(clamp(value + step));
}, [value, step, onChange, clamp]);
const decrement = useCallback(() => {
onChange(clamp(value - step));
}, [value, step, onChange, clamp]);
// Hold-to-repeat for arrow buttons
const startHold = useCallback(
(fn: () => void) => {
fn();
holdTimer.current = setInterval(fn, 120);
},
[]
);
const stopHold = useCallback(() => {
if (holdTimer.current) {
clearInterval(holdTimer.current);
holdTimer.current = null;
}
}, []);
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
if (raw === '' || raw === '-') return;
const v = Number(raw);
if (!isNaN(v)) {
onChange(clamp(v));
}
};
// Format value for display — avoid long decimals
const displayValue = Number.isInteger(step) ? value : Number(value.toFixed(1));
return (
<div className="space-y-1">
{/* Label + value badge */}
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-gray-700 dark:text-dark-text">
{label}
</label>
<span className="text-sm font-semibold text-blue-600 dark:text-blue-400">
{displayValue} {unit}
</span>
</div>
{/* Text input + arrow buttons */}
<div className="flex items-stretch gap-1">
<input
type="number"
value={displayValue}
onChange={handleTextChange}
min={min}
max={max}
step={step}
className="flex-1 px-2 py-1.5 border border-gray-300 dark:border-dark-border dark:bg-dark-bg dark:text-dark-text
rounded-md text-sm text-center font-mono
focus:outline-none focus:ring-2 focus:ring-blue-500
[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<div className="flex flex-col gap-px">
<button
type="button"
onMouseDown={() => startHold(increment)}
onMouseUp={stopHold}
onMouseLeave={stopHold}
className="px-1.5 py-0 text-[10px] leading-none bg-gray-100 dark:bg-dark-border
hover:bg-gray-200 dark:hover:bg-dark-muted border border-gray-300 dark:border-dark-border
rounded-t-md text-gray-600 dark:text-dark-text select-none flex-1 flex items-center justify-center"
title={`+${step}${unit}`}
>
</button>
<button
type="button"
onMouseDown={() => startHold(decrement)}
onMouseUp={stopHold}
onMouseLeave={stopHold}
className="px-1.5 py-0 text-[10px] leading-none bg-gray-100 dark:bg-dark-border
hover:bg-gray-200 dark:hover:bg-dark-muted border border-gray-300 dark:border-dark-border
rounded-b-md text-gray-600 dark:text-dark-text select-none flex-1 flex items-center justify-center"
title={`-${step}${unit}`}
>
</button>
</div>
</div>
{/* Slider for visual feedback */}
{showSlider && (
<input
type="range"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
min={min}
max={max}
step={step}
className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
)}
{/* Min/max labels */}
{showSlider && (
<div className="flex justify-between text-xs text-gray-400 dark:text-dark-muted">
<span>{min}{unit}</span>
<span>{max}{unit}</span>
</div>
)}
{hint && <p className="text-xs text-gray-400 dark:text-dark-muted">{hint}</p>}
</div>
);
}

View File

@@ -1,61 +1,121 @@
import { useEffect } from 'react';
import { useSitesStore } from '@/store/sites.ts';
import { useCoverageStore } from '@/store/coverage.ts';
import { useSettingsStore } from '@/store/settings.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
interface ShortcutHandlers {
onCalculate: () => void;
onCloseForm: () => void;
onShowShortcuts?: () => void;
}
export function useKeyboardShortcuts({ onCalculate, onCloseForm }: ShortcutHandlers) {
function isInputActive(): boolean {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
}
export function useKeyboardShortcuts({
onCalculate,
onCloseForm,
onShowShortcuts,
}: 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;
// === Modifier shortcuts (work even in inputs) ===
if (modKey && e.key === 'Enter') {
e.preventDefault();
onCalculate();
return;
}
case 'n': // Ctrl/Cmd+N: New site (enter placement mode)
// Escape always works
if (e.key === 'Escape') {
useSitesStore.getState().selectSite(null);
useSitesStore.getState().setPlacingMode(false);
onCloseForm();
return;
}
// === Skip remaining shortcuts if typing in an input ===
if (isInputActive()) return;
// Shift combos (no browser conflicts)
if (e.shiftKey && !modKey && !e.altKey) {
switch (e.key.toUpperCase()) {
case 'S': // Shift+S: New site (place mode)
e.preventDefault();
useSitesStore.getState().setPlacingMode(true);
useToastStore.getState().addToast('Click on map to place new site', 'info');
break;
return;
case 'C': // Shift+C: Clear coverage
e.preventDefault();
useCoverageStore.getState().clearCoverage();
useToastStore.getState().addToast('Coverage cleared', 'info');
return;
}
}
// 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) {
// Single letter shortcuts (no modifiers)
if (!modKey && !e.altKey && !e.shiftKey) {
switch (e.key.toLowerCase()) {
case 'h': // H: Toggle heatmap
useCoverageStore.getState().toggleHeatmap();
}
break;
return;
case 'g': // G: Toggle grid
useSettingsStore.getState().setShowGrid(
!useSettingsStore.getState().showGrid
);
return;
case 't': // T: Toggle terrain
useSettingsStore.getState().setShowElevationOverlay(
!useSettingsStore.getState().showElevationOverlay
);
return;
case 'r': // R: Toggle ruler / measurement
useSettingsStore.getState().setMeasurementMode(
!useSettingsStore.getState().measurementMode
);
return;
case 'f': // F: Fit to coverage — dispatch custom event for Map.tsx to handle
window.dispatchEvent(new CustomEvent('rfcp:fit-bounds'));
return;
case 'delete': // Delete key: delete selected sites
case 'backspace':
// Only if a site is selected (not batch)
{
const selectedId = useSitesStore.getState().selectedSiteId;
if (selectedId) {
const site = useSitesStore.getState().sites.find(s => s.id === selectedId);
if (site) {
useSitesStore.getState().deleteSite(selectedId);
useToastStore.getState().addToast(`"${site.name}" deleted`, 'info');
}
}
}
return;
}
// ? key for help (not shift on some layouts)
if (e.key === '?') {
onShowShortcuts?.();
return;
}
}
// ? with shift (most layouts: shift+/)
if (e.shiftKey && !modKey && !e.altKey && e.key === '?') {
onShowShortcuts?.();
return;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onCalculate, onCloseForm]);
}, [onCalculate, onCloseForm, onShowShortcuts]);
}

View File

@@ -38,6 +38,8 @@ interface SitesState {
clearSelection: () => void;
batchUpdateHeight: (adjustment: number) => Promise<void>;
batchSetHeight: (height: number) => Promise<void>;
batchAdjustAzimuth: (delta: number) => Promise<void>;
batchSetAzimuth: (azimuth: number) => Promise<void>;
}
export const useSitesStore = create<SitesState>((set, get) => ({
@@ -276,4 +278,62 @@ export const useSitesStore = create<SitesState>((set, get) => ({
set({ sites: updatedSites });
},
batchAdjustAzimuth: async (delta: number) => {
const { sites, selectedSiteIds } = get();
const selectedSet = new Set(selectedSiteIds);
const now = new Date();
const updatedSites = sites.map((site) => {
if (!selectedSet.has(site.id)) return site;
const current = site.azimuth ?? 0;
return {
...site,
azimuth: ((current + delta) % 360 + 360) % 360,
updatedAt: now,
};
});
const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id));
for (const site of toUpdate) {
await db.sites.put({
id: site.id,
data: JSON.stringify(site),
createdAt: site.createdAt.getTime(),
updatedAt: now.getTime(),
});
}
set({ sites: updatedSites });
useCoverageStore.getState().clearCoverage();
},
batchSetAzimuth: async (azimuth: number) => {
const { sites, selectedSiteIds } = get();
const selectedSet = new Set(selectedSiteIds);
const clamped = ((azimuth % 360) + 360) % 360;
const now = new Date();
const updatedSites = sites.map((site) => {
if (!selectedSet.has(site.id)) return site;
return {
...site,
azimuth: clamped,
updatedAt: now,
};
});
const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id));
for (const site of toUpdate) {
await db.sites.put({
id: site.id,
data: JSON.stringify(site),
createdAt: site.createdAt.getTime(),
updatedAt: now.getTime(),
});
}
set({ sites: updatedSites });
useCoverageStore.getState().clearCoverage();
},
}));