@mytec: iter7 ready for test

This commit is contained in:
2026-01-30 13:06:31 +02:00
parent baebd29e1a
commit 3e1061e369
12 changed files with 592 additions and 39 deletions

View 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>
);
}

View File

@@ -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>

View 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>
);
}

View File

@@ -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) => {