@mytec: iter3.4.0 start

This commit is contained in:
2026-02-02 21:30:00 +02:00
parent 7f0b4d2269
commit 867ee3d0f4
29 changed files with 1386 additions and 324 deletions

View File

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

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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

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

View 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"
>
&times;
</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>
</>
);
}

View File

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

View 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: [] }),
}));

View File

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

View File

@@ -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)
];
/**