@mytec: iter9 ready for test
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
147
frontend/src/components/ui/NumberInput.tsx
Normal file
147
frontend/src/components/ui/NumberInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user