Files
rfcp/frontend/src/components/panels/CoverageStats.tsx

152 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { memo } from 'react';
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 memo(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>
<div className="text-center py-3">
<div className="text-2xl mb-1 opacity-40">📊</div>
<p className="text-xs text-gray-400 dark:text-dark-muted">
No coverage data yet.
</p>
<p className="text-xs text-gray-400 dark:text-dark-muted mt-0.5">
Press <kbd className="px-1 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-[10px] font-mono">Ctrl+Enter</kbd> to calculate.
</p>
</div>
</div>
);
}
const counts = classifyPoints(points);
const totalArea = estimateAreaKm2(points.length, resolution);
const total = points.length;
// Use reduce instead of Math.min/max spread — spread crashes on 65k+ elements
let minRSRP = Infinity;
let maxRSRP = -Infinity;
let sumRSRP = 0;
for (const p of points) {
if (p.rsrp < minRSRP) minRSRP = p.rsrp;
if (p.rsrp > maxRSRP) maxRSRP = p.rsrp;
sumRSRP += p.rsrp;
}
const avgRSRP = sumRSRP / 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>
);
});