@mytec: iter7 ready for test
This commit is contained in:
138
frontend/src/components/panels/CoverageStats.tsx
Normal file
138
frontend/src/components/panels/CoverageStats.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { CoveragePoint } from '@/types/index.ts';
|
||||
|
||||
interface CoverageStatsProps {
|
||||
points: CoveragePoint[];
|
||||
resolution: number; // meters
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate total coverage area from grid points.
|
||||
* Each point represents a resolution × resolution cell.
|
||||
*/
|
||||
function estimateAreaKm2(pointCount: number, resolutionM: number): number {
|
||||
const cellAreaM2 = resolutionM * resolutionM;
|
||||
return (pointCount * cellAreaM2) / 1_000_000;
|
||||
}
|
||||
|
||||
const LEVELS = [
|
||||
{ label: 'Excellent', threshold: -70, color: 'bg-green-500' },
|
||||
{ label: 'Good', threshold: -85, color: 'bg-lime-500' },
|
||||
{ label: 'Fair', threshold: -100, color: 'bg-yellow-500' },
|
||||
{ label: 'Weak', threshold: -Infinity, color: 'bg-red-500' },
|
||||
] as const;
|
||||
|
||||
function classifyPoints(points: CoveragePoint[]) {
|
||||
const counts = { excellent: 0, good: 0, fair: 0, weak: 0 };
|
||||
for (const p of points) {
|
||||
if (p.rsrp > -70) counts.excellent++;
|
||||
else if (p.rsrp > -85) counts.good++;
|
||||
else if (p.rsrp > -100) counts.fair++;
|
||||
else counts.weak++;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export default function CoverageStats({ points, resolution }: CoverageStatsProps) {
|
||||
if (points.length === 0) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text mb-2">
|
||||
Coverage Analysis
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 dark:text-dark-muted">
|
||||
No coverage data. Calculate coverage first.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const counts = classifyPoints(points);
|
||||
const totalArea = estimateAreaKm2(points.length, resolution);
|
||||
const total = points.length;
|
||||
|
||||
const rsrpValues = points.map((p) => p.rsrp);
|
||||
const minRSRP = Math.min(...rsrpValues);
|
||||
const maxRSRP = Math.max(...rsrpValues);
|
||||
const avgRSRP = rsrpValues.reduce((a, b) => a + b, 0) / total;
|
||||
|
||||
// Unique sites contributing to coverage
|
||||
const uniqueSites = new Set(points.map((p) => p.siteId)).size;
|
||||
|
||||
const levels = [
|
||||
{ ...LEVELS[0], count: counts.excellent },
|
||||
{ ...LEVELS[1], count: counts.good },
|
||||
{ ...LEVELS[2], count: counts.fair },
|
||||
{ ...LEVELS[3], count: counts.weak },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
|
||||
Coverage Analysis
|
||||
</h3>
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="bg-gray-50 dark:bg-dark-bg rounded p-2">
|
||||
<div className="text-gray-500 dark:text-dark-muted">Total Area</div>
|
||||
<div className="font-semibold text-gray-800 dark:text-dark-text">
|
||||
{totalArea.toFixed(1)} km²
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-dark-bg rounded p-2">
|
||||
<div className="text-gray-500 dark:text-dark-muted">Grid Points</div>
|
||||
<div className="font-semibold text-gray-800 dark:text-dark-text">
|
||||
{total.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-dark-bg rounded p-2">
|
||||
<div className="text-gray-500 dark:text-dark-muted">Avg RSRP</div>
|
||||
<div className="font-semibold text-gray-800 dark:text-dark-text">
|
||||
{avgRSRP.toFixed(1)} dBm
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-dark-bg rounded p-2">
|
||||
<div className="text-gray-500 dark:text-dark-muted">Sites</div>
|
||||
<div className="font-semibold text-gray-800 dark:text-dark-text">
|
||||
{uniqueSites}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RSRP range */}
|
||||
<div className="text-xs text-gray-500 dark:text-dark-muted">
|
||||
Range: {minRSRP.toFixed(1)} to {maxRSRP.toFixed(1)} dBm
|
||||
</div>
|
||||
|
||||
{/* Signal quality breakdown */}
|
||||
<div className="space-y-1.5">
|
||||
{levels.map((level) => {
|
||||
const pct = total > 0 ? (level.count / total) * 100 : 0;
|
||||
return (
|
||||
<div key={level.label} className="space-y-0.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-700 dark:text-dark-text">
|
||||
{level.label}
|
||||
<span className="text-gray-400 dark:text-dark-muted ml-1">
|
||||
({level.threshold === -Infinity
|
||||
? '< -100'
|
||||
: `> ${level.threshold}`} dBm)
|
||||
</span>
|
||||
</span>
|
||||
<span className="font-medium text-gray-800 dark:text-dark-text">
|
||||
{pct.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${level.color} rounded-full transition-all`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,8 @@ interface SiteFormProps {
|
||||
|
||||
const TEMPLATES = {
|
||||
limesdr: {
|
||||
label: 'LimeSDR',
|
||||
style: 'purple',
|
||||
name: 'LimeSDR Mini',
|
||||
power: 20,
|
||||
gain: 2,
|
||||
@@ -25,6 +27,8 @@ const TEMPLATES = {
|
||||
antennaType: 'omni' as const,
|
||||
},
|
||||
lowBBU: {
|
||||
label: 'Low BBU',
|
||||
style: 'green',
|
||||
name: 'Low Power BBU',
|
||||
power: 40,
|
||||
gain: 8,
|
||||
@@ -33,6 +37,8 @@ const TEMPLATES = {
|
||||
antennaType: 'omni' as const,
|
||||
},
|
||||
highBBU: {
|
||||
label: 'High BBU',
|
||||
style: 'orange',
|
||||
name: 'High Power BBU',
|
||||
power: 43,
|
||||
gain: 15,
|
||||
@@ -42,6 +48,60 @@ const TEMPLATES = {
|
||||
azimuth: 0,
|
||||
beamwidth: 65,
|
||||
},
|
||||
urbanMacro: {
|
||||
label: 'Urban Macro',
|
||||
style: 'blue',
|
||||
name: 'Urban Macro Site',
|
||||
power: 43,
|
||||
gain: 18,
|
||||
frequency: 1800,
|
||||
height: 30,
|
||||
antennaType: 'sector' as const,
|
||||
azimuth: 0,
|
||||
beamwidth: 65,
|
||||
},
|
||||
ruralTower: {
|
||||
label: 'Rural Tower',
|
||||
style: 'emerald',
|
||||
name: 'Rural Tower',
|
||||
power: 46,
|
||||
gain: 8,
|
||||
frequency: 800,
|
||||
height: 50,
|
||||
antennaType: 'omni' as const,
|
||||
},
|
||||
smallCell: {
|
||||
label: 'Small Cell',
|
||||
style: 'cyan',
|
||||
name: 'Small Cell',
|
||||
power: 30,
|
||||
gain: 12,
|
||||
frequency: 2600,
|
||||
height: 6,
|
||||
antennaType: 'sector' as const,
|
||||
azimuth: 0,
|
||||
beamwidth: 90,
|
||||
},
|
||||
indoorDAS: {
|
||||
label: 'Indoor DAS',
|
||||
style: 'rose',
|
||||
name: 'Indoor DAS',
|
||||
power: 23,
|
||||
gain: 2,
|
||||
frequency: 2100,
|
||||
height: 3,
|
||||
antennaType: 'omni' as const,
|
||||
},
|
||||
};
|
||||
|
||||
const TEMPLATE_COLORS: Record<string, string> = {
|
||||
purple: 'bg-purple-100 hover:bg-purple-200 text-purple-700 dark:bg-purple-900/30 dark:hover:bg-purple-900/50 dark:text-purple-300',
|
||||
green: 'bg-green-100 hover:bg-green-200 text-green-700 dark:bg-green-900/30 dark:hover:bg-green-900/50 dark:text-green-300',
|
||||
orange: 'bg-orange-100 hover:bg-orange-200 text-orange-700 dark:bg-orange-900/30 dark:hover:bg-orange-900/50 dark:text-orange-300',
|
||||
blue: 'bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-300',
|
||||
emerald: 'bg-emerald-100 hover:bg-emerald-200 text-emerald-700 dark:bg-emerald-900/30 dark:hover:bg-emerald-900/50 dark:text-emerald-300',
|
||||
cyan: 'bg-cyan-100 hover:bg-cyan-200 text-cyan-700 dark:bg-cyan-900/30 dark:hover:bg-cyan-900/50 dark:text-cyan-300',
|
||||
rose: 'bg-rose-100 hover:bg-rose-200 text-rose-700 dark:bg-rose-900/30 dark:hover:bg-rose-900/50 dark:text-rose-300',
|
||||
};
|
||||
|
||||
export default function SiteForm({
|
||||
@@ -346,33 +406,17 @@ export default function SiteForm({
|
||||
Quick Templates
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyTemplate('limesdr')}
|
||||
className="px-3 py-1.5 bg-purple-100 hover:bg-purple-200 text-purple-700
|
||||
dark:bg-purple-900/30 dark:hover:bg-purple-900/50 dark:text-purple-300
|
||||
rounded text-xs font-medium transition-colors min-h-[32px]"
|
||||
>
|
||||
LimeSDR
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyTemplate('lowBBU')}
|
||||
className="px-3 py-1.5 bg-green-100 hover:bg-green-200 text-green-700
|
||||
dark:bg-green-900/30 dark:hover:bg-green-900/50 dark:text-green-300
|
||||
rounded text-xs font-medium transition-colors min-h-[32px]"
|
||||
>
|
||||
Low BBU
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyTemplate('highBBU')}
|
||||
className="px-3 py-1.5 bg-orange-100 hover:bg-orange-200 text-orange-700
|
||||
dark:bg-orange-900/30 dark:hover:bg-orange-900/50 dark:text-orange-300
|
||||
rounded text-xs font-medium transition-colors min-h-[32px]"
|
||||
>
|
||||
High BBU
|
||||
</button>
|
||||
{Object.entries(TEMPLATES).map(([key, t]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => applyTemplate(key as keyof typeof TEMPLATES)}
|
||||
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors min-h-[32px]
|
||||
${TEMPLATE_COLORS[t.style] ?? TEMPLATE_COLORS.blue}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
146
frontend/src/components/panels/SiteImportExport.tsx
Normal file
146
frontend/src/components/panels/SiteImportExport.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useRef } from 'react';
|
||||
import { useSitesStore } from '@/store/sites.ts';
|
||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||
import Button from '@/components/ui/Button.tsx';
|
||||
|
||||
/**
|
||||
* Import/Export site configurations as JSON.
|
||||
* Export downloads the current site list; Import merges (appends) sites.
|
||||
*/
|
||||
export default function SiteImportExport() {
|
||||
const sites = useSitesStore((s) => s.sites);
|
||||
const importSites = useSitesStore((s) => s.importSites);
|
||||
const addToast = useToastStore((s) => s.addToast);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleExport = () => {
|
||||
if (sites.length === 0) {
|
||||
addToast('No sites to export', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip internal fields (id, createdAt, updatedAt) so import can reassign them
|
||||
const exportData = sites.map((s) => ({
|
||||
name: s.name,
|
||||
lat: s.lat,
|
||||
lon: s.lon,
|
||||
height: s.height,
|
||||
power: s.power,
|
||||
gain: s.gain,
|
||||
frequency: s.frequency,
|
||||
antennaType: s.antennaType,
|
||||
azimuth: s.azimuth,
|
||||
beamwidth: s.beamwidth,
|
||||
color: s.color,
|
||||
visible: s.visible,
|
||||
notes: s.notes,
|
||||
equipment: s.equipment,
|
||||
}));
|
||||
|
||||
const json = JSON.stringify(exportData, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `rfcp-sites-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
addToast(`Exported ${sites.length} site(s) as JSON`, 'success');
|
||||
};
|
||||
|
||||
const handleImport = async (file: File) => {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
addToast('Invalid file format: expected an array of sites', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
const valid = parsed.filter(
|
||||
(s: Record<string, unknown>) =>
|
||||
typeof s.name === 'string' &&
|
||||
typeof s.lat === 'number' &&
|
||||
typeof s.lon === 'number'
|
||||
);
|
||||
|
||||
if (valid.length === 0) {
|
||||
addToast('No valid sites found in file', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Map to SiteFormData shape
|
||||
const sitesData = valid.map((s: Record<string, unknown>) => ({
|
||||
name: s.name as string,
|
||||
lat: s.lat as number,
|
||||
lon: s.lon as number,
|
||||
height: (s.height as number) ?? 30,
|
||||
power: (s.power as number) ?? 43,
|
||||
gain: (s.gain as number) ?? 8,
|
||||
frequency: (s.frequency as number) ?? 1800,
|
||||
antennaType: ((s.antennaType as string) === 'sector' ? 'sector' : 'omni') as 'omni' | 'sector',
|
||||
azimuth: s.azimuth as number | undefined,
|
||||
beamwidth: s.beamwidth as number | undefined,
|
||||
color: (s.color as string) ?? '',
|
||||
visible: (s.visible as boolean) ?? true,
|
||||
notes: s.notes as string | undefined,
|
||||
equipment: s.equipment as string | undefined,
|
||||
}));
|
||||
|
||||
const count = await importSites(sitesData);
|
||||
addToast(`Imported ${count} site(s)`, 'success');
|
||||
} catch (error) {
|
||||
console.error('Import failed:', error);
|
||||
addToast('Invalid JSON file', 'error');
|
||||
}
|
||||
|
||||
// Reset file input so same file can be re-imported
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
|
||||
Site Import / Export
|
||||
</h3>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleExport} size="sm" variant="secondary">
|
||||
Export JSON
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Import JSON
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{sites.length > 0 && (
|
||||
<p className="text-xs text-gray-500 dark:text-dark-muted">
|
||||
{sites.length} site(s) configured
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleImport(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
const toggleSiteSelection = useSitesStore((s) => s.toggleSiteSelection);
|
||||
const selectAllSites = useSitesStore((s) => s.selectAllSites);
|
||||
const clearSelection = useSitesStore((s) => s.clearSelection);
|
||||
const cloneSiteAsSectors = useSitesStore((s) => s.cloneSiteAsSectors);
|
||||
const cloneSector = useSitesStore((s) => s.cloneSector);
|
||||
const addToast = useToastStore((s) => s.addToast);
|
||||
|
||||
// Track recently batch-updated site IDs for flash animation
|
||||
@@ -153,13 +153,13 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await cloneSiteAsSectors(site.id, 3);
|
||||
addToast(`Created 3 sectors from "${site.name}"`, 'success');
|
||||
await cloneSector(site.id);
|
||||
addToast(`Cloned "${site.name}" (+30° azimuth)`, '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="Clone as 3-sector site (120° spacing)"
|
||||
title="Clone sector (+30° azimuth offset)"
|
||||
>
|
||||
3S
|
||||
Clone
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
||||
Reference in New Issue
Block a user