@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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user