@mytec: iter3.4.0 start
This commit is contained in:
@@ -19,6 +19,8 @@ import SiteList from '@/components/panels/SiteList.tsx';
|
||||
import ExportPanel from '@/components/panels/ExportPanel.tsx';
|
||||
import ProjectPanel from '@/components/panels/ProjectPanel.tsx';
|
||||
import CoverageStats from '@/components/panels/CoverageStats.tsx';
|
||||
import HistoryPanel from '@/components/panels/HistoryPanel.tsx';
|
||||
import ResultsPanel from '@/components/panels/ResultsPanel.tsx';
|
||||
import SiteImportExport from '@/components/panels/SiteImportExport.tsx';
|
||||
import { SiteConfigModal } from '@/components/modals/index.ts';
|
||||
import type { SiteFormValues } from '@/components/modals/index.ts';
|
||||
@@ -394,8 +396,8 @@ export default function App() {
|
||||
const currentSettings = useCoverageStore.getState().settings;
|
||||
|
||||
// Validation
|
||||
if (currentSettings.radius > 100) {
|
||||
addToast('Radius too large (max 100km)', 'error');
|
||||
if (currentSettings.radius > 50) {
|
||||
addToast('Radius too large (max 50km)', 'error');
|
||||
return;
|
||||
}
|
||||
if (currentSettings.resolution < 50) {
|
||||
@@ -406,9 +408,17 @@ export default function App() {
|
||||
try {
|
||||
await calculateCoverageApi();
|
||||
|
||||
// Check result after calculation
|
||||
const result = useCoverageStore.getState().result;
|
||||
const error = useCoverageStore.getState().error;
|
||||
// After calculateCoverageApi returns, check if WS took over.
|
||||
// In WS mode, the function returns immediately and result arrives asynchronously.
|
||||
const state = useCoverageStore.getState();
|
||||
if (state.isCalculating && state.activeCalcId) {
|
||||
// WebSocket mode — toast will be shown from the WS onResult callback
|
||||
return;
|
||||
}
|
||||
|
||||
// HTTP mode — result is ready now
|
||||
const result = state.result;
|
||||
const error = state.error;
|
||||
|
||||
if (error) {
|
||||
let userMessage = 'Calculation failed';
|
||||
@@ -666,6 +676,7 @@ export default function App() {
|
||||
)}
|
||||
</MapView>
|
||||
<HeatmapLegend />
|
||||
<ResultsPanel />
|
||||
</div>
|
||||
|
||||
{/* Side panel */}
|
||||
@@ -706,14 +717,15 @@ export default function App() {
|
||||
<NumberInput
|
||||
label="Radius"
|
||||
value={settings.radius}
|
||||
onChange={(v) =>
|
||||
useCoverageStore.getState().updateSettings({ radius: v })
|
||||
}
|
||||
onChange={(v) => {
|
||||
const clamped = Math.min(v, 50);
|
||||
useCoverageStore.getState().updateSettings({ radius: clamped });
|
||||
}}
|
||||
min={1}
|
||||
max={100}
|
||||
max={50}
|
||||
step={5}
|
||||
unit="km"
|
||||
hint="Calculation area around each site"
|
||||
hint="Calculation area around each site (max 50km)"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Resolution"
|
||||
@@ -1174,6 +1186,9 @@ export default function App() {
|
||||
modelsUsed={coverageResult?.modelsUsed}
|
||||
/>
|
||||
|
||||
{/* Session history */}
|
||||
<HistoryPanel />
|
||||
|
||||
{/* Export coverage data */}
|
||||
<ExportPanel />
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function CoverageBoundary({
|
||||
points,
|
||||
visible,
|
||||
resolution,
|
||||
color = '#7c3aed', // purple-600 — visible against both map and orange gradient
|
||||
color = '#ffffff', // white — visible against red-to-blue gradient
|
||||
weight = 2,
|
||||
}: CoverageBoundaryProps) {
|
||||
const map = useMap();
|
||||
|
||||
@@ -13,12 +13,11 @@ import { useSitesStore } from '@/store/sites.ts';
|
||||
|
||||
const LEGEND_STEPS = [
|
||||
{ rsrp: -130, label: 'No Service' },
|
||||
{ rsrp: -110, label: 'Very Weak' },
|
||||
{ rsrp: -100, label: 'Weak' },
|
||||
{ rsrp: -90, label: 'Fair' },
|
||||
{ rsrp: -80, label: 'Good' },
|
||||
{ rsrp: -70, label: 'Strong' },
|
||||
{ rsrp: -50, label: 'Excellent' },
|
||||
{ rsrp: -110, label: 'Weak' },
|
||||
{ rsrp: -100, label: 'Fair' },
|
||||
{ rsrp: -85, label: 'Good' },
|
||||
{ rsrp: -70, label: 'Excellent' },
|
||||
{ rsrp: -50, label: 'Max' },
|
||||
];
|
||||
|
||||
/** Build a CSS linear-gradient string matching the heatmap gradient exactly. */
|
||||
@@ -106,9 +105,9 @@ export default function HeatmapLegend() {
|
||||
|
||||
{/* Cutoff indicator + below-threshold (dimmed) */}
|
||||
{belowThreshold.length > 0 && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-dashed border-purple-400 dark:border-purple-500">
|
||||
<div className="mt-1.5 pt-1.5 border-t border-dashed border-gray-400 dark:border-gray-500">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<span className="text-[9px] text-purple-500 dark:text-purple-400 font-medium">
|
||||
<span className="text-[9px] text-gray-500 dark:text-gray-400 font-medium">
|
||||
─ ─ Coverage boundary ({threshold} dBm)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import NumberInput from '@/components/ui/NumberInput.tsx';
|
||||
import FrequencySelector from '@/components/panels/FrequencySelector.tsx';
|
||||
import FrequencyBandPanel from '@/components/panels/FrequencyBandPanel.tsx';
|
||||
import ModalBackdrop from './ModalBackdrop.tsx';
|
||||
|
||||
@@ -31,6 +30,7 @@ interface SiteConfigModalProps {
|
||||
const TEMPLATES = {
|
||||
limesdr: {
|
||||
label: 'LimeSDR',
|
||||
tooltip: 'SDR dev board — low power, short range testing (20 dBm, 2 dBi, 1800 MHz)',
|
||||
style: 'purple',
|
||||
name: 'LimeSDR Mini',
|
||||
power: 20,
|
||||
@@ -41,6 +41,7 @@ const TEMPLATES = {
|
||||
},
|
||||
lowBBU: {
|
||||
label: 'Low BBU',
|
||||
tooltip: 'Low-power baseband unit — suburban/campus coverage (40 dBm, 8 dBi, 1800 MHz)',
|
||||
style: 'green',
|
||||
name: 'Low Power BBU',
|
||||
power: 40,
|
||||
@@ -51,6 +52,7 @@ const TEMPLATES = {
|
||||
},
|
||||
highBBU: {
|
||||
label: 'High BBU',
|
||||
tooltip: 'High-power BBU — urban macro sector (43 dBm, 15 dBi, 65\u00B0 sector)',
|
||||
style: 'orange',
|
||||
name: 'High Power BBU',
|
||||
power: 43,
|
||||
@@ -63,6 +65,7 @@ const TEMPLATES = {
|
||||
},
|
||||
urbanMacro: {
|
||||
label: 'Urban Macro',
|
||||
tooltip: 'Standard urban macro site — rooftop/tower sector (43 dBm, 18 dBi, 65\u00B0 sector)',
|
||||
style: 'blue',
|
||||
name: 'Urban Macro Site',
|
||||
power: 43,
|
||||
@@ -75,6 +78,7 @@ const TEMPLATES = {
|
||||
},
|
||||
ruralTower: {
|
||||
label: 'Rural Tower',
|
||||
tooltip: 'Rural high tower — long range 800 MHz omni coverage (46 dBm, 8 dBi, 50m)',
|
||||
style: 'emerald',
|
||||
name: 'Rural Tower',
|
||||
power: 46,
|
||||
@@ -85,6 +89,7 @@ const TEMPLATES = {
|
||||
},
|
||||
smallCell: {
|
||||
label: 'Small Cell',
|
||||
tooltip: 'Urban small cell — street-level high capacity (30 dBm, 12 dBi, 2600 MHz)',
|
||||
style: 'cyan',
|
||||
name: 'Small Cell',
|
||||
power: 30,
|
||||
@@ -97,6 +102,7 @@ const TEMPLATES = {
|
||||
},
|
||||
indoorDAS: {
|
||||
label: 'Indoor DAS',
|
||||
tooltip: 'Indoor distributed antenna — in-building coverage (23 dBm, 2 dBi, 2100 MHz)',
|
||||
style: 'rose',
|
||||
name: 'Indoor DAS',
|
||||
power: 23,
|
||||
@@ -107,6 +113,7 @@ const TEMPLATES = {
|
||||
},
|
||||
uhfTactical: {
|
||||
label: 'UHF Tactical',
|
||||
tooltip: 'UHF tactical radio — man-portable field comms (25 dBm, 3 dBi, 450 MHz)',
|
||||
style: 'amber',
|
||||
name: 'UHF Tactical Radio',
|
||||
power: 25,
|
||||
@@ -117,6 +124,7 @@ const TEMPLATES = {
|
||||
},
|
||||
vhfRepeater: {
|
||||
label: 'VHF Repeater',
|
||||
tooltip: 'VHF repeater — long range voice/data relay (40 dBm, 6 dBi, 150 MHz)',
|
||||
style: 'teal',
|
||||
name: 'VHF Repeater',
|
||||
power: 40,
|
||||
@@ -203,8 +211,8 @@ export default function SiteConfigModal({
|
||||
if (form.power < 10 || form.power > 50) {
|
||||
newErrors.power = 'Power must be 10-50 dBm';
|
||||
}
|
||||
if (form.gain < 0 || form.gain > 25) {
|
||||
newErrors.gain = 'Gain must be 0-25 dBi';
|
||||
if (form.gain < 0 || form.gain > 30) {
|
||||
newErrors.gain = 'Gain must be 0-30 dBi';
|
||||
}
|
||||
if (form.frequency < 100 || form.frequency > 6000) {
|
||||
newErrors.frequency = 'Frequency must be 100-6000 MHz';
|
||||
@@ -360,20 +368,20 @@ export default function SiteConfigModal({
|
||||
label="Antenna Gain"
|
||||
value={form.gain}
|
||||
min={0}
|
||||
max={25}
|
||||
max={30}
|
||||
step={0.5}
|
||||
unit="dBi"
|
||||
hint="Omni 2-8, Sector 15-18, Parabolic 20-25"
|
||||
hint={
|
||||
form.gain <= 8
|
||||
? `Omni-directional (${form.gain} dBi)`
|
||||
: form.gain <= 18
|
||||
? `Sector/Panel (${form.gain} dBi)`
|
||||
: `Parabolic/Dish (${form.gain} dBi)`
|
||||
}
|
||||
onChange={(v) => updateField('gain', v)}
|
||||
/>
|
||||
|
||||
{/* Frequency */}
|
||||
<FrequencySelector
|
||||
value={form.frequency}
|
||||
onChange={(v) => updateField('frequency', v)}
|
||||
/>
|
||||
|
||||
{/* Band panel — UHF/VHF/LTE/5G grouped selector */}
|
||||
{/* Band panel — UHF/VHF/LTE/5G grouped selector + custom input */}
|
||||
<FrequencyBandPanel
|
||||
value={form.frequency}
|
||||
onChange={(v) => updateField('frequency', v)}
|
||||
@@ -485,6 +493,7 @@ export default function SiteConfigModal({
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => applyTemplate(key as keyof typeof TEMPLATES)}
|
||||
title={t.tooltip}
|
||||
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors min-h-[32px]
|
||||
${TEMPLATE_COLORS[t.style] ?? TEMPLATE_COLORS.blue}`}
|
||||
>
|
||||
|
||||
@@ -19,8 +19,8 @@ function estimateAreaKm2(pointCount: number, resolutionM: number): number {
|
||||
}
|
||||
|
||||
const LEVELS = [
|
||||
{ label: 'Excellent', threshold: -70, color: 'bg-green-500' },
|
||||
{ label: 'Good', threshold: -85, color: 'bg-lime-500' },
|
||||
{ label: 'Excellent', threshold: -70, color: 'bg-blue-500' },
|
||||
{ label: 'Good', threshold: -85, color: 'bg-green-500' },
|
||||
{ label: 'Fair', threshold: -100, color: 'bg-yellow-500' },
|
||||
{ label: 'Weak', threshold: -Infinity, color: 'bg-red-500' },
|
||||
] as const;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* and propagation model info for each band.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { COMMON_FREQUENCIES, FREQUENCY_GROUPS, getWavelength } from '@/constants/frequencies.ts';
|
||||
import type { FrequencyBand } from '@/types/index.ts';
|
||||
|
||||
@@ -54,11 +55,25 @@ function getBandForFrequency(freq: number): string | null {
|
||||
|
||||
export default function FrequencyBandPanel({ value, onChange }: FrequencyBandPanelProps) {
|
||||
const currentBand = getBandForFrequency(value);
|
||||
const [customInput, setCustomInput] = useState('');
|
||||
|
||||
const handleCustomSubmit = () => {
|
||||
const parsed = parseInt(customInput, 10);
|
||||
if (parsed > 0 && parsed <= 100000) {
|
||||
onChange(parsed);
|
||||
setCustomInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-semibold text-gray-500 dark:text-dark-muted uppercase tracking-wide">
|
||||
Frequency Bands
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-semibold text-gray-500 dark:text-dark-muted uppercase tracking-wide">
|
||||
Operating Frequency
|
||||
</div>
|
||||
<div className="text-xs font-medium text-gray-600 dark:text-dark-muted">
|
||||
{value} MHz
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(Object.keys(FREQUENCY_GROUPS) as Array<keyof typeof FREQUENCY_GROUPS>).map((bandType) => {
|
||||
@@ -139,6 +154,28 @@ export default function FrequencyBandPanel({ value, onChange }: FrequencyBandPan
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom frequency input */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Custom MHz..."
|
||||
value={customInput}
|
||||
onChange={(e) => setCustomInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCustomSubmit()}
|
||||
className="flex-1 px-2.5 py-1.5 border border-gray-300 dark:border-dark-border dark:bg-dark-bg dark:text-dark-text rounded-md text-xs
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
min={1}
|
||||
max={100000}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCustomSubmit}
|
||||
className="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 dark:bg-dark-border dark:hover:bg-dark-muted dark:text-dark-text rounded-md text-xs text-gray-700 min-h-[28px]"
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
137
frontend/src/components/panels/HistoryPanel.tsx
Normal file
137
frontend/src/components/panels/HistoryPanel.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState } from 'react';
|
||||
import { useCalcHistoryStore } from '@/store/calcHistory.ts';
|
||||
import type { CalculationEntry } from '@/store/calcHistory.ts';
|
||||
|
||||
function EntryDetail({ entry }: { entry: CalculationEntry }) {
|
||||
return (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-gray-100 dark:border-dark-border space-y-1.5 text-[10px]">
|
||||
{/* Coverage breakdown with percentages */}
|
||||
<div className="grid grid-cols-4 gap-1 text-center">
|
||||
<div>
|
||||
<div className="font-semibold text-blue-600 dark:text-blue-400">
|
||||
{entry.coverage.excellent.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-gray-400">Excellent</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-green-600 dark:text-green-400">
|
||||
{entry.coverage.good.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-gray-400">Good</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-yellow-600 dark:text-yellow-400">
|
||||
{entry.coverage.fair.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-gray-400">Fair</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-red-600 dark:text-red-400">
|
||||
{entry.coverage.weak.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-gray-400">Weak</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RSRP details */}
|
||||
<div className="flex justify-between text-gray-500 dark:text-dark-muted">
|
||||
<span>Avg RSRP: {entry.avgRsrp.toFixed(1)} dBm</span>
|
||||
<span>Range: {entry.rangeMin.toFixed(0)} / {entry.rangeMax.toFixed(0)} dBm</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HistoryPanel() {
|
||||
const entries = useCalcHistoryStore((s) => s.entries);
|
||||
const clearHistory = useCalcHistoryStore((s) => s.clearHistory);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [expandedEntry, setExpandedEntry] = useState<string | null>(null);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-1 text-sm font-semibold text-gray-800 dark:text-dark-text"
|
||||
>
|
||||
<span className="text-[10px]">{expanded ? '\u25BC' : '\u25B6'}</span>
|
||||
Session History
|
||||
<span className="text-xs text-gray-400 dark:text-dark-muted font-normal ml-1">
|
||||
({entries.length})
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<button
|
||||
onClick={clearHistory}
|
||||
className="text-[10px] text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-2 space-y-1.5 max-h-80 overflow-y-auto">
|
||||
{entries.map((entry) => {
|
||||
const isOpen = expandedEntry === entry.id;
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
onClick={() => setExpandedEntry(isOpen ? null : entry.id)}
|
||||
className="w-full text-left text-xs border border-gray-100 dark:border-dark-border rounded p-2 space-y-1 hover:bg-gray-50 dark:hover:bg-dark-bg transition-colors cursor-pointer"
|
||||
>
|
||||
{/* Row 1: timestamp + computation time */}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-500 dark:text-dark-muted">
|
||||
{entry.timestamp.toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="font-bold text-gray-800 dark:text-dark-text">
|
||||
{entry.computationTime.toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2: badges */}
|
||||
<div className="flex gap-1.5 flex-wrap text-[10px]">
|
||||
<span className="px-1 py-0.5 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 rounded">
|
||||
{entry.preset}
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-dark-muted">
|
||||
{entry.totalPoints.toLocaleString()} pts
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-dark-muted">
|
||||
{entry.radius}km
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-dark-muted">
|
||||
{entry.resolution}m
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Coverage bar */}
|
||||
<div className="flex h-1.5 rounded-full overflow-hidden bg-gray-100 dark:bg-dark-border">
|
||||
{entry.coverage.excellent > 0 && (
|
||||
<div className="bg-blue-500" style={{ width: `${entry.coverage.excellent}%` }} />
|
||||
)}
|
||||
{entry.coverage.good > 0 && (
|
||||
<div className="bg-green-500" style={{ width: `${entry.coverage.good}%` }} />
|
||||
)}
|
||||
{entry.coverage.fair > 0 && (
|
||||
<div className="bg-yellow-500" style={{ width: `${entry.coverage.fair}%` }} />
|
||||
)}
|
||||
{entry.coverage.weak > 0 && (
|
||||
<div className="bg-red-500" style={{ width: `${entry.coverage.weak}%` }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable detail */}
|
||||
{isOpen && <EntryDetail entry={entry} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
frontend/src/components/panels/ResultsPanel.tsx
Normal file
163
frontend/src/components/panels/ResultsPanel.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useCoverageStore } from '@/store/coverage.ts';
|
||||
import type { CoverageResult } from '@/types/index.ts';
|
||||
|
||||
function classifyCoverage(points: Array<{ rsrp: number }>) {
|
||||
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;
|
||||
}
|
||||
|
||||
const AUTO_DISMISS_MS = 10_000;
|
||||
|
||||
export default function ResultsPanel() {
|
||||
const result = useCoverageStore((s) => s.result);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [show, setShow] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const prevResultRef = useRef<CoverageResult | null>(null);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setVisible(false);
|
||||
setTimeout(() => setShow(false), 300);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Only trigger on NEW result (not initial mount with existing result)
|
||||
if (result && result !== prevResultRef.current && result.points.length > 0) {
|
||||
setShow(true);
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(dismiss, AUTO_DISMISS_MS);
|
||||
}
|
||||
prevResultRef.current = result;
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [result, dismiss]);
|
||||
|
||||
if (!show || !result) return null;
|
||||
|
||||
const counts = classifyCoverage(result.points);
|
||||
const total = result.points.length;
|
||||
const preset = result.settings.preset ?? 'standard';
|
||||
const timeStr = result.calculationTime.toFixed(1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`@keyframes rfcp-shrink { from { width: 100%; } to { width: 0%; } }`}</style>
|
||||
<div
|
||||
className={`absolute top-4 left-4 z-[1000] w-72
|
||||
bg-white/95 dark:bg-dark-surface/95 backdrop-blur-sm
|
||||
border border-gray-200 dark:border-dark-border rounded-lg shadow-lg
|
||||
transition-all duration-300 ease-out pointer-events-auto
|
||||
${visible ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-8'}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 pt-3 pb-1">
|
||||
<h3 className="text-xs font-semibold text-gray-700 dark:text-dark-text">
|
||||
Calculation Complete
|
||||
</h3>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-dark-text text-sm leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-3 pb-3 space-y-2">
|
||||
{/* Time + points */}
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-lg font-bold text-gray-800 dark:text-dark-text">
|
||||
{timeStr}s
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-dark-muted">
|
||||
{total.toLocaleString()} points
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Coverage breakdown bar */}
|
||||
<div className="flex h-2 rounded-full overflow-hidden">
|
||||
{counts.excellent > 0 && (
|
||||
<div className="bg-blue-500" style={{ width: `${(counts.excellent / total) * 100}%` }} />
|
||||
)}
|
||||
{counts.good > 0 && (
|
||||
<div className="bg-green-500" style={{ width: `${(counts.good / total) * 100}%` }} />
|
||||
)}
|
||||
{counts.fair > 0 && (
|
||||
<div className="bg-yellow-500" style={{ width: `${(counts.fair / total) * 100}%` }} />
|
||||
)}
|
||||
{counts.weak > 0 && (
|
||||
<div className="bg-red-500" style={{ width: `${(counts.weak / total) * 100}%` }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Coverage percentages */}
|
||||
<div className="grid grid-cols-4 gap-1 text-center text-[10px]">
|
||||
<div>
|
||||
<div className="font-semibold text-blue-600 dark:text-blue-400">
|
||||
{total > 0 ? ((counts.excellent / total) * 100).toFixed(0) : 0}%
|
||||
</div>
|
||||
<div className="text-gray-400">Exc</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-green-600 dark:text-green-400">
|
||||
{total > 0 ? ((counts.good / total) * 100).toFixed(0) : 0}%
|
||||
</div>
|
||||
<div className="text-gray-400">Good</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-yellow-600 dark:text-yellow-400">
|
||||
{total > 0 ? ((counts.fair / total) * 100).toFixed(0) : 0}%
|
||||
</div>
|
||||
<div className="text-gray-400">Fair</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-red-600 dark:text-red-400">
|
||||
{total > 0 ? ((counts.weak / total) * 100).toFixed(0) : 0}%
|
||||
</div>
|
||||
<div className="text-gray-400">Weak</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex flex-wrap gap-1.5 text-[10px] text-gray-500 dark:text-dark-muted">
|
||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded">
|
||||
{preset}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded">
|
||||
{result.settings.radius}km
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded">
|
||||
{result.settings.resolution}m
|
||||
</span>
|
||||
{result.modelsUsed && result.modelsUsed.length > 0 && (
|
||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded">
|
||||
{result.modelsUsed.length} models
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-dismiss progress bar */}
|
||||
<div className="h-0.5 bg-gray-100 dark:bg-dark-border rounded-b-lg overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-400 dark:bg-blue-500"
|
||||
style={{
|
||||
animation: `rfcp-shrink ${AUTO_DISMISS_MS}ms linear forwards`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ interface PendingCalc {
|
||||
class WebSocketService {
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private pingTimer: ReturnType<typeof setInterval> | undefined;
|
||||
private _connected = false;
|
||||
private _pendingCalcs = new Map<string, PendingCalc>();
|
||||
private _connectionListeners = new Set<ConnectionCallback>();
|
||||
@@ -70,10 +71,20 @@ class WebSocketService {
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this._setConnected(true);
|
||||
// Keepalive pings every 30s to prevent connection timeout during long calculations
|
||||
if (this.pingTimer) clearInterval(this.pingTimer);
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 30_000);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this._setConnected(false);
|
||||
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = undefined; }
|
||||
// Fail all pending calculations — their callbacks reference the old socket
|
||||
this._failPendingCalcs('WebSocket disconnected');
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), 2000);
|
||||
};
|
||||
|
||||
@@ -121,8 +132,18 @@ class WebSocketService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Fail all pending calculations (e.g. on disconnect). */
|
||||
private _failPendingCalcs(reason: string): void {
|
||||
for (const [calcId, pending] of this._pendingCalcs) {
|
||||
try { pending.onError(reason); } catch { /* ignore */ }
|
||||
this._pendingCalcs.delete(calcId);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = undefined; }
|
||||
this._failPendingCalcs('WebSocket disconnected');
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
this._setConnected(false);
|
||||
|
||||
38
frontend/src/store/calcHistory.ts
Normal file
38
frontend/src/store/calcHistory.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface CalculationEntry {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
preset: string;
|
||||
radius: number;
|
||||
resolution: number;
|
||||
computationTime: number;
|
||||
totalPoints: number;
|
||||
coverage: { excellent: number; good: number; fair: number; weak: number };
|
||||
avgRsrp: number;
|
||||
rangeMin: number;
|
||||
rangeMax: number;
|
||||
}
|
||||
|
||||
interface CalcHistoryState {
|
||||
entries: CalculationEntry[];
|
||||
addEntry: (entry: CalculationEntry) => void;
|
||||
clearHistory: () => void;
|
||||
}
|
||||
|
||||
const MAX_ENTRIES = 50;
|
||||
|
||||
export const useCalcHistoryStore = create<CalcHistoryState>((set) => ({
|
||||
entries: [],
|
||||
|
||||
addEntry: (entry) =>
|
||||
set((state) => {
|
||||
const entries = [entry, ...state.entries];
|
||||
if (entries.length > MAX_ENTRIES) {
|
||||
entries.length = MAX_ENTRIES;
|
||||
}
|
||||
return { entries };
|
||||
}),
|
||||
|
||||
clearHistory: () => set({ entries: [] }),
|
||||
}));
|
||||
@@ -3,6 +3,9 @@ import { api } from '@/services/api.ts';
|
||||
import { wsService } from '@/services/websocket.ts';
|
||||
import type { WSProgress } from '@/services/websocket.ts';
|
||||
import { useSitesStore } from '@/store/sites.ts';
|
||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||
import { useCalcHistoryStore } from '@/store/calcHistory.ts';
|
||||
import type { CalculationEntry } from '@/store/calcHistory.ts';
|
||||
import type { CoverageResult, CoverageSettings, CoverageApiStats } from '@/types/index.ts';
|
||||
import type { ApiSiteParams, CoverageResponse } from '@/services/api.ts';
|
||||
|
||||
@@ -49,7 +52,7 @@ function buildApiSettings(settings: CoverageSettings) {
|
||||
return {
|
||||
radius: settings.radius * 1000, // km → meters
|
||||
resolution: settings.resolution,
|
||||
min_signal: settings.rsrpThreshold,
|
||||
min_signal: -130, // Send all useful points; frontend filters visually via rsrpThreshold
|
||||
preset: settings.preset,
|
||||
use_terrain: settings.use_terrain,
|
||||
use_buildings: settings.use_buildings,
|
||||
@@ -92,6 +95,44 @@ function responseToResult(response: CoverageResponse, settings: CoverageSettings
|
||||
};
|
||||
}
|
||||
|
||||
function buildHistoryEntry(result: CoverageResult): CalculationEntry {
|
||||
const counts = { excellent: 0, good: 0, fair: 0, weak: 0 };
|
||||
let minRsrp = Infinity;
|
||||
let maxRsrp = -Infinity;
|
||||
|
||||
for (const p of result.points) {
|
||||
if (p.rsrp > -70) counts.excellent++;
|
||||
else if (p.rsrp > -85) counts.good++;
|
||||
else if (p.rsrp > -100) counts.fair++;
|
||||
else counts.weak++;
|
||||
if (p.rsrp < minRsrp) minRsrp = p.rsrp;
|
||||
if (p.rsrp > maxRsrp) maxRsrp = p.rsrp;
|
||||
}
|
||||
|
||||
const total = result.points.length;
|
||||
const avgRsrp = result.stats?.avg_rsrp
|
||||
?? (total > 0 ? result.points.reduce((s, p) => s + p.rsrp, 0) / total : 0);
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: new Date(),
|
||||
preset: result.settings.preset ?? 'standard',
|
||||
radius: result.settings.radius,
|
||||
resolution: result.settings.resolution,
|
||||
computationTime: result.calculationTime,
|
||||
totalPoints: result.totalPoints,
|
||||
coverage: {
|
||||
excellent: total > 0 ? (counts.excellent / total) * 100 : 0,
|
||||
good: total > 0 ? (counts.good / total) * 100 : 0,
|
||||
fair: total > 0 ? (counts.fair / total) * 100 : 0,
|
||||
weak: total > 0 ? (counts.weak / total) * 100 : 0,
|
||||
},
|
||||
avgRsrp,
|
||||
rangeMin: minRsrp === Infinity ? 0 : minRsrp,
|
||||
rangeMax: maxRsrp === -Infinity ? 0 : maxRsrp,
|
||||
};
|
||||
}
|
||||
|
||||
export const useCoverageStore = create<CoverageState>((set, get) => ({
|
||||
result: null,
|
||||
isCalculating: false,
|
||||
@@ -163,12 +204,36 @@ export const useCoverageStore = create<CoverageState>((set, get) => ({
|
||||
apiSettings as unknown as Record<string, unknown>,
|
||||
// onResult
|
||||
(data) => {
|
||||
const result = responseToResult(data, settings);
|
||||
set({ result, isCalculating: false, error: null, progress: null, activeCalcId: null });
|
||||
try {
|
||||
const result = responseToResult(data, settings);
|
||||
set({ result, isCalculating: false, error: null, progress: null, activeCalcId: null });
|
||||
// Show success toast for WS result
|
||||
const addToast = useToastStore.getState().addToast;
|
||||
if (result.points.length === 0) {
|
||||
addToast('No coverage points. Try increasing radius.', 'warning');
|
||||
} else {
|
||||
const timeStr = result.calculationTime.toFixed(1);
|
||||
const modelsStr = result.modelsUsed?.length
|
||||
? ` \u2022 ${result.modelsUsed.length} models`
|
||||
: '';
|
||||
addToast(
|
||||
`Calculated ${result.totalPoints.toLocaleString()} points in ${timeStr}s${modelsStr}`,
|
||||
'success'
|
||||
);
|
||||
}
|
||||
// Push to session history
|
||||
if (result.points.length > 0) {
|
||||
useCalcHistoryStore.getState().addEntry(buildHistoryEntry(result));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Coverage] Failed to process result:', err);
|
||||
set({ isCalculating: false, error: 'Failed to process coverage result', progress: null, activeCalcId: null });
|
||||
}
|
||||
},
|
||||
// onError
|
||||
(error) => {
|
||||
set({ isCalculating: false, error, progress: null, activeCalcId: null });
|
||||
useToastStore.getState().addToast(`Calculation failed: ${error}`, 'error');
|
||||
},
|
||||
// onProgress
|
||||
(progress) => {
|
||||
@@ -191,6 +256,10 @@ export const useCoverageStore = create<CoverageState>((set, get) => ({
|
||||
|
||||
const result = responseToResult(response, settings);
|
||||
set({ result, isCalculating: false, error: null });
|
||||
// Push to session history
|
||||
if (result.points.length > 0) {
|
||||
useCalcHistoryStore.getState().addEntry(buildHistoryEntry(result));
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
set({ isCalculating: false });
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* RSRP → color mapping with smooth gradient interpolation.
|
||||
*
|
||||
* Purple → Orange palette:
|
||||
* -130 dBm = deep purple (no service)
|
||||
* -90 dBm = peach (fair)
|
||||
* -50 dBm = bright orange (excellent)
|
||||
* CloudRF-style Red → Blue palette:
|
||||
* -130 dBm = dark red (no service)
|
||||
* -100 dBm = yellow (fair)
|
||||
* -70 dBm = green (good)
|
||||
* -50 dBm = deep blue (excellent)
|
||||
*
|
||||
* All functions are pure and allocation-free on the hot path
|
||||
* (pre-built lookup table for fast per-pixel color resolution).
|
||||
@@ -18,14 +19,13 @@ interface GradientStop {
|
||||
}
|
||||
|
||||
const GRADIENT_STOPS: GradientStop[] = [
|
||||
{ value: 0.0, r: 26, g: 0, b: 51 }, // #1a0033 — deep purple (no service)
|
||||
{ value: 0.15, r: 74, g: 20, b: 140 }, // #4a148c — dark purple
|
||||
{ value: 0.30, r: 123, g: 31, b: 162 }, // #7b1fa2 — purple (very weak)
|
||||
{ value: 0.45, r: 171, g: 71, b: 188 }, // #ab47bc — light purple (weak)
|
||||
{ value: 0.60, r: 255, g: 138, b: 101 }, // #ff8a65 — peach (fair)
|
||||
{ value: 0.75, r: 255, g: 111, b: 0 }, // #ff6f00 — dark orange (good)
|
||||
{ value: 0.85, r: 255, g: 152, b: 0 }, // #ff9800 — orange (strong)
|
||||
{ value: 1.0, r: 255, g: 183, b: 77 }, // #ffb74d — bright orange (excellent)
|
||||
{ value: 0.0, r: 127, g: 0, b: 0 }, // #7f0000 — dark red (no service)
|
||||
{ value: 0.15, r: 239, g: 68, b: 68 }, // #EF4444 — red (very weak)
|
||||
{ value: 0.30, r: 249, g: 115, b: 22 }, // #F97316 — orange (weak)
|
||||
{ value: 0.50, r: 234, g: 179, b: 8 }, // #EAB308 — yellow (fair)
|
||||
{ value: 0.70, r: 34, g: 197, b: 94 }, // #22C55E — green (good)
|
||||
{ value: 0.85, r: 59, g: 130, b: 246 }, // #3B82F6 — blue (strong)
|
||||
{ value: 1.0, r: 37, g: 99, b: 235 }, // #2563EB — deep blue (excellent)
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user