@mytec: iter9 ready for test
This commit is contained in:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user