152 lines
5.5 KiB
TypeScript
152 lines
5.5 KiB
TypeScript
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>
|
||
);
|
||
});
|