Compare commits

...

2 Commits

43 changed files with 6271 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(npm create:*)",
"Bash(npm install:*)",
"Bash(npx tsc:*)",
"Bash(npm run build:*)"
]
}
}

251
RFCP-Fixes-Iteration1.md Normal file
View File

@@ -0,0 +1,251 @@
# RFCP Fixes - Iteration 1
## Issues to Fix:
### 1. Heatmap Colors - More Obvious Gradient
**Current problem:** Green→Yellow→Red gradient not obvious enough
**Fix:** Update `src/components/map/Heatmap.tsx`
Change the gradient to use more distinct colors with better visibility:
```typescript
// In Heatmap.tsx, update the gradient prop:
<HeatmapLayer
points={heatmapPoints}
longitudeExtractor={(p: any) => p[1]}
latitudeExtractor={(p: any) => p[0]}
intensityExtractor={(p: any) => p[2]}
gradient={{
0.0: '#0000ff', // Blue (very weak, -120 dBm)
0.2: '#00ffff', // Cyan (weak, -110 dBm)
0.4: '#00ff00', // Green (fair, -100 dBm)
0.6: '#ffff00', // Yellow (good, -85 dBm)
0.8: '#ff7f00', // Orange (strong, -70 dBm)
1.0: '#ff0000', // Red (excellent, > -70 dBm)
}}
radius={25}
blur={15}
max={1.0}
/>
```
**Reasoning:**
- Blue/Cyan for weak signals (more intuitive - "cold" = weak)
- Green for acceptable
- Yellow/Orange for good
- Red for excellent (hot = strong)
This is actually inverted from typical "green=good", but in RF planning, RED = STRONG SIGNAL = GOOD!
---
### 2. Coverage Radius - Increase to 100km
**Current problem:** Max radius only 20km, not enough for tactical planning
**Fix:** Update `src/components/panels/SiteForm.tsx` (or wherever Coverage Settings are)
Find the radius slider and change:
```typescript
// Old:
<Slider
label="Radius (km)"
min={1}
max={20} // ← Change this
step={1}
value={coverageSettings.radius}
onChange={(value) => updateCoverageSettings({ radius: value })}
/>
// New:
<Slider
label="Radius (km)"
min={1}
max={100} // ← Increased to 100km
step={5} // ← Larger step for easier control
value={coverageSettings.radius}
onChange={(value) => updateCoverageSettings({ radius: value })}
help="Calculation area around each site"
/>
```
**Also update default value in store:**
```typescript
// In src/store/coverage.ts or wherever coverage settings are initialized:
const initialSettings = {
radius: 10, // Default 10km (reasonable starting point)
resolution: 200, // 200m resolution
rsrpThreshold: -120
};
```
---
### 3. Save & Calculate Button
**Current problem:** Two separate buttons, extra click needed
**Fix:** Update `src/components/panels/SiteForm.tsx`
Replace the button section:
```typescript
// Old:
<div className="flex gap-2">
<Button onClick={handleSave}>Save</Button>
<Button onClick={handleDelete} variant="danger">Delete</Button>
</div>
// New:
<div className="flex gap-2">
<Button
onClick={handleSaveAndCalculate}
variant="primary"
className="flex-1"
>
💾 Save & Calculate Coverage
</Button>
<Button
onClick={handleSave}
variant="secondary"
className="flex-1"
>
💾 Save Only
</Button>
<Button
onClick={handleDelete}
variant="danger"
>
🗑 Delete
</Button>
</div>
// Add the handler:
const handleSaveAndCalculate = async () => {
// Save the site first
await handleSave();
// Then trigger coverage calculation
const sites = useSitesStore.getState().sites;
const coverage = useCoverageStore.getState();
// Calculate coverage for all sites
await coverage.calculateCoverage(sites);
// Show success toast
toast.success('Site saved and coverage calculated!');
};
```
**Alternative (simpler):** Just make the main button do both:
```typescript
<div className="flex gap-2">
<Button
onClick={async () => {
await handleSave();
// Auto-trigger calculate after save
const sites = useSitesStore.getState().sites;
await useCoverageStore.getState().calculateCoverage(sites);
}}
variant="primary"
className="flex-1"
>
💾 Save & Calculate
</Button>
<Button
onClick={handleDelete}
variant="danger"
>
🗑 Delete
</Button>
</div>
```
---
### 4. Legend Colors Update
**Fix:** Update `src/components/map/Legend.tsx` to match new gradient:
```typescript
const signalRanges = [
{ label: 'Excellent', range: '> -70 dBm', color: '#ff0000' }, // Red
{ label: 'Good', range: '-70 to -85 dBm', color: '#ff7f00' }, // Orange
{ label: 'Fair', range: '-85 to -100 dBm', color: '#ffff00' }, // Yellow
{ label: 'Poor', range: '-100 to -110 dBm', color: '#00ff00' }, // Green
{ label: 'Weak', range: '-110 to -120 dBm', color: '#00ffff' }, // Cyan
{ label: 'Very Weak', range: '< -120 dBm', color: '#0000ff' }, // Blue
];
```
---
### 5. Resolution Adjustment for Large Radius
**Problem:** 200m resolution + 100km radius = MANY points = slow calculation
**Fix:** Auto-adjust resolution based on radius:
```typescript
// In src/store/coverage.ts or coverage calculator:
const getOptimalResolution = (radius: number): number => {
if (radius <= 10) return 100; // 100m for small areas
if (radius <= 30) return 200; // 200m for medium areas
if (radius <= 60) return 300; // 300m for large areas
return 500; // 500m for very large areas (100km)
};
// Use in calculation:
const resolution = settings.resolution || getOptimalResolution(settings.radius);
```
Or add a notice in UI:
```typescript
<p className="text-sm text-gray-500">
💡 Larger radius = longer calculation time.
Consider increasing resolution (200m 500m) for faster results.
</p>
```
---
## Summary of Changes:
1.**Heatmap gradient:** Blue → Cyan → Green → Yellow → Orange → Red
2.**Max radius:** 20km → 100km (step: 5km)
3.**Button:** "Save & Calculate" as primary action
4.**Legend:** Updated colors to match new gradient
5.**Performance:** Auto-adjust resolution or show warning
---
## Implementation Order:
1. **Start with colors** (easiest, biggest visual impact)
2. **Then radius** (simple slider change)
3. **Then button** (requires store integration)
4. **Test everything**
---
## Files to Edit:
```
frontend/src/components/map/Heatmap.tsx - gradient colors
frontend/src/components/map/Legend.tsx - legend colors
frontend/src/components/panels/SiteForm.tsx - radius slider + button
frontend/src/store/coverage.ts - default settings
```
---
Would you like me to create the exact code patches for Claude Code to apply?
Or should I create complete replacement files?

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

16
frontend/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2563eb" />
<meta name="description" content="RFCP - RF Coverage Planning Tool" />
<title>RFCP - RF Coverage Planner</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3726
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
frontend/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"dexie": "^4.2.1",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
"uuid": "^13.0.0",
"zustand": "^5.0.10"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
"@types/leaflet": "^1.9.21",
"@types/leaflet.heat": "^0.2.5",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@@ -0,0 +1,18 @@
{
"name": "RFCP - RF Coverage Planner",
"short_name": "RFCP",
"description": "RF Coverage Planning Tool for wireless network deployment",
"start_url": "/",
"display": "standalone",
"background_color": "#1e293b",
"theme_color": "#2563eb",
"orientation": "any",
"icons": [
{
"src": "/vite.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,123 @@
// RF Coverage Calculation Web Worker
// Runs in a separate thread for parallel processing
self.onmessage = function (e) {
const { type, sites, points, rsrpThreshold } = e.data;
if (type === 'calculate') {
try {
const results = [];
for (let i = 0; i < points.length; i++) {
const point = points[i];
let bestRSRP = -Infinity;
let bestSiteId = '';
for (let j = 0; j < sites.length; j++) {
const site = sites[j];
const rsrp = calculatePointRSRP(site, point);
if (rsrp > bestRSRP) {
bestRSRP = rsrp;
bestSiteId = site.id;
}
}
if (bestRSRP >= rsrpThreshold) {
results.push({
lat: point.lat,
lon: point.lon,
rsrp: bestRSRP,
siteId: bestSiteId,
});
}
}
self.postMessage({ type: 'complete', results });
} catch (error) {
self.postMessage({ type: 'error', message: error.message });
}
}
};
/**
* Calculate RSRP at a specific point (universal formula)
*/
function calculatePointRSRP(site, point) {
var distance = haversineDistance(site.lat, site.lon, point.lat, point.lon);
// Minimum distance to prevent -Infinity
if (distance < 0.01) distance = 0.01;
// Free space path loss (universal)
var fspl =
20 * Math.log10(distance) + 20 * Math.log10(site.frequency) + 32.45;
// Link budget: RSRP = P_tx + G_tx - FSPL
var rsrp = site.power + site.gain - fspl;
// Apply sector antenna pattern loss
if (site.antennaType === 'sector' && site.azimuth !== undefined) {
var bearing = calculateBearing(site.lat, site.lon, point.lat, point.lon);
var relativeAngle = Math.abs(bearing - site.azimuth);
var normalizedAngle =
relativeAngle > 180 ? 360 - relativeAngle : relativeAngle;
var patternLoss = calculateSectorPatternLoss(
normalizedAngle,
site.beamwidth || 65
);
rsrp -= patternLoss;
}
return rsrp;
}
/**
* Haversine distance in km
*/
function haversineDistance(lat1, lon1, lat2, lon2) {
var R = 6371;
var dLat = ((lat2 - lat1) * Math.PI) / 180;
var dLon = ((lon2 - lon1) * Math.PI) / 180;
var a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* Calculate bearing from A to B in degrees (0-360)
*/
function calculateBearing(lat1, lon1, lat2, lon2) {
var dLon = ((lon2 - lon1) * Math.PI) / 180;
var lat1Rad = (lat1 * Math.PI) / 180;
var lat2Rad = (lat2 * Math.PI) / 180;
var y = Math.sin(dLon) * Math.cos(lat2Rad);
var x =
Math.cos(lat1Rad) * Math.sin(lat2Rad) -
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon);
var bearing = (Math.atan2(y, x) * 180) / Math.PI;
return (bearing + 360) % 360;
}
/**
* Sector antenna pattern loss (3GPP model)
*/
function calculateSectorPatternLoss(angleOffBoresight, beamwidth) {
var theta3dB = beamwidth / 2;
var sideLobeLevel = 20;
return Math.min(
12 * Math.pow(angleOffBoresight / theta3dB, 2),
sideLobeLevel
);
}

270
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,270 @@
import { useEffect, useState, useCallback } from 'react';
import type { Site } from '@/types/index.ts';
import { useSitesStore } from '@/store/sites.ts';
import { useCoverageStore } from '@/store/coverage.ts';
import { RFCalculator } from '@/rf/calculator.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
import MapView from '@/components/map/Map.tsx';
import Heatmap from '@/components/map/Heatmap.tsx';
import Legend from '@/components/map/Legend.tsx';
import SiteList from '@/components/panels/SiteList.tsx';
import SiteForm from '@/components/panels/SiteForm.tsx';
import ToastContainer from '@/components/ui/Toast.tsx';
import Button from '@/components/ui/Button.tsx';
const calculator = new RFCalculator();
export default function App() {
const loadSites = useSitesStore((s) => s.loadSites);
const sites = useSitesStore((s) => s.sites);
const setPlacingMode = useSitesStore((s) => s.setPlacingMode);
const coverageResult = useCoverageStore((s) => s.result);
const isCalculating = useCoverageStore((s) => s.isCalculating);
const settings = useCoverageStore((s) => s.settings);
const setResult = useCoverageStore((s) => s.setResult);
const setIsCalculating = useCoverageStore((s) => s.setIsCalculating);
const heatmapVisible = useCoverageStore((s) => s.heatmapVisible);
const addToast = useToastStore((s) => s.addToast);
const [showForm, setShowForm] = useState(false);
const [editSite, setEditSite] = useState<Site | null>(null);
const [pendingLocation, setPendingLocation] = useState<{
lat: number;
lon: number;
} | null>(null);
const [panelCollapsed, setPanelCollapsed] = useState(false);
// Load sites from IndexedDB on mount
useEffect(() => {
loadSites();
}, [loadSites]);
// Handle map click -> open form with coordinates
const handleMapClick = useCallback(
(lat: number, lon: number) => {
setPendingLocation({ lat, lon });
setEditSite(null);
setShowForm(true);
setPlacingMode(false);
},
[setPlacingMode]
);
const handleEditSite = useCallback((site: Site) => {
setEditSite(site);
setPendingLocation(null);
setShowForm(true);
}, []);
const handleAddManual = useCallback(() => {
setEditSite(null);
setPendingLocation(null);
setShowForm(true);
}, []);
const handleCloseForm = useCallback(() => {
setShowForm(false);
setEditSite(null);
setPendingLocation(null);
}, []);
// Calculate coverage
const handleCalculate = useCallback(async () => {
if (sites.length === 0) {
addToast('Add at least one site first', 'error');
return;
}
setIsCalculating(true);
try {
// Calculate bounds from sites with radius buffer
const latitudes = sites.map((s) => s.lat);
const longitudes = sites.map((s) => s.lon);
const radiusDeg = settings.radius / 111;
const avgLat =
(Math.max(...latitudes) + Math.min(...latitudes)) / 2;
const lonRadiusDeg =
radiusDeg / Math.cos((avgLat * Math.PI) / 180);
const bounds = {
north: Math.max(...latitudes) + radiusDeg,
south: Math.min(...latitudes) - radiusDeg,
east: Math.max(...longitudes) + lonRadiusDeg,
west: Math.min(...longitudes) - lonRadiusDeg,
};
const result = await calculator.calculateCoverage(
sites,
bounds,
settings
);
setResult(result);
addToast(
`Coverage calculated: ${result.totalPoints.toLocaleString()} points in ${(result.calculationTime / 1000).toFixed(2)}s`,
'success'
);
} catch (err) {
addToast(
`Calculation error: ${err instanceof Error ? err.message : 'Unknown error'}`,
'error'
);
} finally {
setIsCalculating(false);
}
}, [sites, settings, setIsCalculating, setResult, addToast]);
return (
<div className="h-screen w-screen flex flex-col bg-gray-100">
{/* Header */}
<header className="bg-slate-800 text-white px-4 py-2 flex items-center justify-between flex-shrink-0 z-10">
<div className="flex items-center gap-2">
<span className="text-base font-bold">RFCP</span>
<span className="text-xs text-slate-400 hidden sm:inline">
RF Coverage Planner
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={isCalculating ? 'secondary' : 'primary'}
onClick={handleCalculate}
disabled={isCalculating || sites.length === 0}
>
{isCalculating ? (
<span className="flex items-center gap-1">
<span className="animate-spin inline-block w-3 h-3 border-2 border-white border-t-transparent rounded-full" />
Calculating...
</span>
) : (
'Calculate Coverage'
)}
</Button>
<button
onClick={() => setPanelCollapsed(!panelCollapsed)}
className="text-slate-400 hover:text-white text-sm sm:hidden"
>
{panelCollapsed ? 'Show' : 'Hide'}
</button>
</div>
</header>
{/* Main content */}
<div className="flex-1 flex overflow-hidden relative">
{/* Map */}
<div className="flex-1 relative">
<MapView onMapClick={handleMapClick} onEditSite={handleEditSite}>
{coverageResult && (
<Heatmap
points={coverageResult.points}
visible={heatmapVisible}
/>
)}
</MapView>
<Legend />
</div>
{/* Side panel */}
<div
className={`${
panelCollapsed ? 'hidden' : 'flex'
} flex-col w-full sm:w-80 lg:w-96 bg-gray-50 border-l border-gray-200
overflow-y-auto absolute sm:relative inset-0 sm:inset-auto z-[1001]`}
>
{/* Close button on mobile */}
<div className="sm:hidden p-2">
<button
onClick={() => setPanelCollapsed(true)}
className="text-gray-500 hover:text-gray-700 text-sm"
>
Close Panel
</button>
</div>
<div className="p-3 space-y-3 flex-1 overflow-y-auto">
{/* Site list */}
<SiteList onEditSite={handleEditSite} onAddSite={handleAddManual} />
{/* Site form */}
{showForm && (
<SiteForm
editSite={editSite}
pendingLocation={pendingLocation}
onClose={handleCloseForm}
onClearPending={() => setPendingLocation(null)}
/>
)}
{/* Coverage settings */}
<div className="bg-white border border-gray-200 rounded-lg shadow-sm p-4 space-y-3">
<h3 className="text-sm font-semibold text-gray-800">
Coverage Settings
</h3>
<div className="space-y-2">
<div>
<label className="text-xs text-gray-500">
Radius: {settings.radius} km
</label>
<input
type="range"
min={1}
max={20}
value={settings.radius}
onChange={(e) =>
useCoverageStore
.getState()
.updateSettings({ radius: Number(e.target.value) })
}
className="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
<div>
<label className="text-xs text-gray-500">
Resolution: {settings.resolution}m
</label>
<input
type="range"
min={50}
max={500}
step={50}
value={settings.resolution}
onChange={(e) =>
useCoverageStore
.getState()
.updateSettings({ resolution: Number(e.target.value) })
}
className="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
<div>
<label className="text-xs text-gray-500">
Min Signal: {settings.rsrpThreshold} dBm
</label>
<input
type="range"
min={-140}
max={-70}
step={5}
value={settings.rsrpThreshold}
onChange={(e) =>
useCoverageStore
.getState()
.updateSettings({
rsrpThreshold: Number(e.target.value),
})
}
className="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<ToastContainer />
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { useEffect } from 'react';
import { useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet.heat';
import type { CoveragePoint } from '@/types/index.ts';
// Extend L with heat layer type
declare module 'leaflet' {
function heatLayer(
latlngs: Array<[number, number, number]>,
options?: Record<string, unknown>
): L.Layer;
}
interface HeatmapProps {
points: CoveragePoint[];
visible: boolean;
}
/**
* Normalize RSRP to 0-1 intensity for heatmap.
* -120 dBm -> 0.0 (very weak)
* -70 dBm -> 1.0 (excellent)
*/
function rsrpToIntensity(rsrp: number): number {
const min = -120;
const max = -60;
return Math.max(0, Math.min(1, (rsrp - min) / (max - min)));
}
export default function Heatmap({ points, visible }: HeatmapProps) {
const map = useMap();
useEffect(() => {
if (!visible || points.length === 0) return;
const heatData: Array<[number, number, number]> = points.map((p) => [
p.lat,
p.lon,
rsrpToIntensity(p.rsrp),
]);
const heatLayer = L.heatLayer(heatData, {
radius: 15,
blur: 20,
maxZoom: 17,
max: 1.0,
minOpacity: 0.3,
gradient: {
0.0: '#ef4444', // red (weak)
0.2: '#f97316', // orange (poor)
0.4: '#eab308', // yellow (fair)
0.6: '#84cc16', // lime (good)
0.8: '#22c55e', // green (excellent)
1.0: '#16a34a', // dark green (very strong)
},
});
heatLayer.addTo(map);
return () => {
map.removeLayer(heatLayer);
};
}, [map, points, visible]);
return null;
}

View File

@@ -0,0 +1,52 @@
import { RSRP_LEGEND } from '@/constants/rsrp-thresholds.ts';
import { useCoverageStore } from '@/store/coverage.ts';
export default function Legend() {
const result = useCoverageStore((s) => s.result);
const heatmapVisible = useCoverageStore((s) => s.heatmapVisible);
const toggleHeatmap = useCoverageStore((s) => s.toggleHeatmap);
if (!result) return null;
return (
<div className="absolute bottom-6 right-2 z-[1000] bg-white rounded-lg shadow-lg border border-gray-200 p-3 min-w-[160px]">
{/* Header with toggle */}
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold text-gray-700">Signal (RSRP)</h3>
<button
onClick={toggleHeatmap}
className={`w-8 h-4 rounded-full transition-colors relative
${heatmapVisible ? 'bg-blue-500' : 'bg-gray-300'}`}
>
<span
className={`absolute top-0.5 w-3 h-3 rounded-full bg-white shadow transition-transform
${heatmapVisible ? 'left-4' : 'left-0.5'}`}
/>
</button>
</div>
{/* Legend items */}
<div className="space-y-1">
{RSRP_LEGEND.map((item) => (
<div key={item.label} className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-sm flex-shrink-0"
style={{ backgroundColor: item.color }}
/>
<span className="text-[10px] text-gray-600">
{item.label}: {item.range}
</span>
</div>
))}
</div>
{/* Stats */}
<div className="mt-2 pt-2 border-t border-gray-100 text-[10px] text-gray-400 space-y-0.5">
<div>Points: {result.totalPoints.toLocaleString()}</div>
<div>
Time: {(result.calculationTime / 1000).toFixed(2)}s
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { MapContainer, TileLayer, useMapEvents } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import type { Site } from '@/types/index.ts';
import { useSitesStore } from '@/store/sites.ts';
import SiteMarker from './SiteMarker.tsx';
interface MapViewProps {
onMapClick: (lat: number, lon: number) => void;
onEditSite: (site: Site) => void;
children?: React.ReactNode;
}
function MapClickHandler({
onMapClick,
}: {
onMapClick: (lat: number, lon: number) => void;
}) {
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
useMapEvents({
click: (e) => {
if (isPlacingMode) {
onMapClick(e.latlng.lat, e.latlng.lng);
}
},
});
return null;
}
export default function MapView({ onMapClick, onEditSite, children }: MapViewProps) {
const sites = useSitesStore((s) => s.sites);
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
return (
<MapContainer
center={[48.4, 35.0]}
zoom={7}
className={`w-full h-full ${isPlacingMode ? 'cursor-crosshair' : ''}`}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapClickHandler onMapClick={onMapClick} />
{sites
.filter((s) => s.visible)
.map((site) => (
<SiteMarker key={site.id} site={site} onEdit={onEditSite} />
))}
{children}
</MapContainer>
);
}

View File

@@ -0,0 +1,88 @@
import { Marker, Popup, useMap } from 'react-leaflet';
import L from 'leaflet';
import type { Site } from '@/types/index.ts';
import { useSitesStore } from '@/store/sites.ts';
import { useEffect } from 'react';
interface SiteMarkerProps {
site: Site;
onEdit: (site: Site) => void;
}
function createSiteIcon(color: string, isSelected: boolean): L.DivIcon {
const size = isSelected ? 16 : 12;
const border = isSelected ? '3px solid white' : '2px solid white';
return L.divIcon({
className: '',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
html: `<div style="
width: ${size}px;
height: ${size}px;
border-radius: 50%;
background: ${color};
border: ${border};
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
"></div>`,
});
}
function FlyToSelected({ site, isSelected }: { site: Site; isSelected: boolean }) {
const map = useMap();
useEffect(() => {
if (isSelected) {
map.flyTo([site.lat, site.lon], map.getZoom(), { duration: 0.5 });
}
}, [isSelected, site.lat, site.lon, map]);
return null;
}
export default function SiteMarker({ site, onEdit }: SiteMarkerProps) {
const selectedSiteId = useSitesStore((s) => s.selectedSiteId);
const selectSite = useSitesStore((s) => s.selectSite);
const updateSite = useSitesStore((s) => s.updateSite);
const isSelected = selectedSiteId === site.id;
return (
<>
<FlyToSelected site={site} isSelected={isSelected} />
<Marker
position={[site.lat, site.lon]}
icon={createSiteIcon(site.color, isSelected)}
draggable
eventHandlers={{
click: () => selectSite(site.id),
dragend: (e) => {
const marker = e.target as L.Marker;
const pos = marker.getLatLng();
updateSite(site.id, { lat: pos.lat, lon: pos.lng });
},
}}
>
<Popup>
<div className="text-xs space-y-1 min-w-[160px]">
<div className="font-semibold">{site.name}</div>
<div>
{site.frequency} MHz · {site.power} dBm · {site.gain} dBi
</div>
<div>
Height: {site.height}m · {site.antennaType}
</div>
{site.notes && (
<div className="text-gray-500">{site.notes}</div>
)}
<div className="pt-1">
<button
onClick={() => onEdit(site)}
className="text-blue-600 hover:underline text-xs"
>
Edit
</button>
</div>
</div>
</Popup>
</Marker>
</>
);
}

View File

@@ -0,0 +1,102 @@
import { useState } from 'react';
import {
QUICK_FREQUENCIES,
getFrequencyInfo,
getWavelength,
} from '@/constants/frequencies.ts';
interface FrequencySelectorProps {
value: number;
onChange: (freq: number) => void;
}
export default function FrequencySelector({
value,
onChange,
}: FrequencySelectorProps) {
const [customInput, setCustomInput] = useState('');
const bandInfo = getFrequencyInfo(value);
const handleCustomSubmit = () => {
const parsed = parseInt(customInput, 10);
if (parsed > 0 && parsed <= 100000) {
onChange(parsed);
setCustomInput('');
}
};
return (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
Operating Frequency
</label>
{/* Quick buttons */}
<div className="flex flex-wrap gap-1.5">
{QUICK_FREQUENCIES.map((freq) => (
<button
key={freq}
type="button"
onClick={() => onChange(freq)}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors
${
value === freq
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{freq}
</button>
))}
<span className="self-center text-xs text-gray-400">MHz</span>
</div>
{/* Custom 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-3 py-1.5 border border-gray-300 rounded-md text-sm
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 rounded-md text-sm text-gray-700"
>
Set
</button>
</div>
{/* Current frequency display */}
<div className="text-sm text-gray-600 font-medium">
Current: {value} MHz
</div>
{/* Band info */}
{bandInfo ? (
<div className="bg-blue-50 border border-blue-200 rounded-md p-2 text-xs space-y-0.5">
<div className="font-semibold text-blue-700">
{bandInfo.name} ({bandInfo.range})
</div>
<div className="text-blue-600">
λ = {getWavelength(value)} · {bandInfo.characteristics.range} range ·{' '}
{bandInfo.characteristics.penetration} penetration
</div>
<div className="text-blue-500">{bandInfo.characteristics.typical}</div>
</div>
) : (
<div className="bg-gray-50 border border-gray-200 rounded-md p-2 text-xs">
<div className="text-gray-600">
Custom frequency: {value} MHz · λ = {getWavelength(value)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,324 @@
import { useState, useEffect } from 'react';
import type { Site } from '@/types/index.ts';
import { useSitesStore } from '@/store/sites.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
import Button from '@/components/ui/Button.tsx';
import Input from '@/components/ui/Input.tsx';
import Slider from '@/components/ui/Slider.tsx';
import FrequencySelector from './FrequencySelector.tsx';
interface SiteFormProps {
editSite?: Site | null;
pendingLocation?: { lat: number; lon: number } | null;
onClose: () => void;
onClearPending?: () => void;
}
const TEMPLATES = {
limesdr: { name: 'LimeSDR', power: 20, gain: 3, frequency: 1800, height: 5 },
lowBBU: { name: 'Low BBU', power: 37, gain: 15, frequency: 1800, height: 25 },
highBBU: { name: 'High BBU', power: 46, gain: 18, frequency: 1800, height: 35 },
};
export default function SiteForm({
editSite,
pendingLocation,
onClose,
onClearPending,
}: SiteFormProps) {
const addSite = useSitesStore((s) => s.addSite);
const updateSite = useSitesStore((s) => s.updateSite);
const addToast = useToastStore((s) => s.addToast);
const [name, setName] = useState(editSite?.name ?? 'Station-1');
const [lat, setLat] = useState(editSite?.lat ?? pendingLocation?.lat ?? 48.4647);
const [lon, setLon] = useState(editSite?.lon ?? pendingLocation?.lon ?? 35.0462);
const [power, setPower] = useState(editSite?.power ?? 43);
const [gain, setGain] = useState(editSite?.gain ?? 8);
const [frequency, setFrequency] = useState(editSite?.frequency ?? 1800);
const [height, setHeight] = useState(editSite?.height ?? 30);
const [antennaType, setAntennaType] = useState<'omni' | 'sector'>(
editSite?.antennaType ?? 'omni'
);
const [azimuth, setAzimuth] = useState(editSite?.azimuth ?? 0);
const [beamwidth, setBeamwidth] = useState(editSite?.beamwidth ?? 65);
const [notes, setNotes] = useState(editSite?.notes ?? '');
useEffect(() => {
if (pendingLocation) {
setLat(pendingLocation.lat);
setLon(pendingLocation.lon);
}
}, [pendingLocation]);
const applyTemplate = (key: keyof typeof TEMPLATES) => {
const t = TEMPLATES[key];
setName(t.name);
setPower(t.power);
setGain(t.gain);
setFrequency(t.frequency);
setHeight(t.height);
};
const handleSubmit = async () => {
if (editSite) {
await updateSite(editSite.id, {
name,
lat,
lon,
power,
gain,
frequency,
height,
antennaType,
azimuth: antennaType === 'sector' ? azimuth : undefined,
beamwidth: antennaType === 'sector' ? beamwidth : undefined,
notes: notes || undefined,
});
addToast('Site updated', 'success');
} else {
await addSite({
name,
lat,
lon,
power,
gain,
frequency,
height,
antennaType,
azimuth: antennaType === 'sector' ? azimuth : undefined,
beamwidth: antennaType === 'sector' ? beamwidth : undefined,
color: '',
visible: true,
notes: notes || undefined,
});
addToast('Site added', 'success');
}
onClearPending?.();
onClose();
};
return (
<div className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-y-auto max-h-[calc(100vh-120px)]">
<div className="sticky top-0 bg-white border-b border-gray-200 px-4 py-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-800">
{editSite ? 'Edit Site' : 'New Site Configuration'}
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-lg"
>
×
</button>
</div>
<div className="p-4 space-y-4">
{/* Site name */}
<Input
label="Site Name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Station-1"
/>
{/* Coordinates */}
<div className="space-y-1">
<label className="text-sm font-medium text-gray-700">Coordinates</label>
<div className="flex gap-2">
<div className="flex-1">
<input
type="number"
step="0.0001"
value={lat}
onChange={(e) => setLat(Number(e.target.value))}
className="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Lat"
/>
</div>
<div className="flex-1">
<input
type="number"
step="0.0001"
value={lon}
onChange={(e) => setLon(Number(e.target.value))}
className="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Lon"
/>
</div>
</div>
</div>
{/* Separator */}
<div className="border-t border-gray-200 pt-2">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
RF Parameters
</span>
</div>
{/* Power */}
<Slider
label="Transmit Power (dBm)"
value={power}
min={10}
max={50}
unit="dBm"
hint="LimeSDR 20, BBU 43, RRU 46"
onChange={setPower}
/>
{/* Gain */}
<Slider
label="Antenna Gain (dBi)"
value={gain}
min={0}
max={25}
unit="dBi"
hint="Omni 2-8, Sector 15-18, Parabolic 20-25"
onChange={setGain}
/>
{/* Frequency */}
<FrequencySelector value={frequency} onChange={setFrequency} />
{/* Separator */}
<div className="border-t border-gray-200 pt-2">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Physical Parameters
</span>
</div>
{/* Height */}
<Slider
label="Antenna Height (meters)"
value={height}
min={1}
max={100}
unit="m"
hint="Height from ground to antenna center"
onChange={setHeight}
/>
{/* Antenna type */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
Antenna Type
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="antennaType"
checked={antennaType === 'omni'}
onChange={() => setAntennaType('omni')}
className="accent-blue-600"
/>
<span className="text-sm">Omni</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="antennaType"
checked={antennaType === 'sector'}
onChange={() => setAntennaType('sector')}
className="accent-blue-600"
/>
<span className="text-sm">Sector</span>
</label>
</div>
</div>
{/* Sector parameters - collapsible */}
{antennaType === 'sector' && (
<div className="bg-gray-50 rounded-md p-3 space-y-3 border border-gray-200">
<Slider
label="Azimuth (degrees)"
value={azimuth}
min={0}
max={360}
unit="°"
onChange={setAzimuth}
/>
<Slider
label="Beamwidth (degrees)"
value={beamwidth}
min={30}
max={120}
unit="°"
onChange={setBeamwidth}
/>
</div>
)}
{/* Separator */}
<div className="border-t border-gray-200 pt-2">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Notes
</span>
</div>
{/* Notes */}
<div className="space-y-1">
<label className="text-sm font-medium text-gray-700">
Equipment / Notes (optional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="e.g., ZTE B8200 BBU + custom omni antenna"
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
resize-none"
/>
</div>
{/* Quick Templates */}
{!editSite && (
<div className="space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Quick Templates
</label>
<div className="flex gap-2 flex-wrap">
<button
type="button"
onClick={() => applyTemplate('limesdr')}
className="px-2 py-1 bg-purple-100 hover:bg-purple-200 text-purple-700
rounded text-xs font-medium transition-colors"
>
LimeSDR
</button>
<button
type="button"
onClick={() => applyTemplate('lowBBU')}
className="px-2 py-1 bg-green-100 hover:bg-green-200 text-green-700
rounded text-xs font-medium transition-colors"
>
Low BBU
</button>
<button
type="button"
onClick={() => applyTemplate('highBBU')}
className="px-2 py-1 bg-orange-100 hover:bg-orange-200 text-orange-700
rounded text-xs font-medium transition-colors"
>
High BBU
</button>
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-2">
<Button onClick={handleSubmit} className="flex-1">
{editSite ? 'Save Changes' : 'Add Site'}
</Button>
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import type { Site } from '@/types/index.ts';
import { useSitesStore } from '@/store/sites.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
import Button from '@/components/ui/Button.tsx';
interface SiteListProps {
onEditSite: (site: Site) => void;
onAddSite: () => void;
}
export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
const sites = useSitesStore((s) => s.sites);
const deleteSite = useSitesStore((s) => s.deleteSite);
const selectSite = useSitesStore((s) => s.selectSite);
const selectedSiteId = useSitesStore((s) => s.selectedSiteId);
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
const togglePlacingMode = useSitesStore((s) => s.togglePlacingMode);
const addToast = useToastStore((s) => s.addToast);
const handleDelete = async (id: string, name: string) => {
await deleteSite(id);
addToast(`"${name}" deleted`, 'info');
};
return (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
<div className="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-800">
Sites ({sites.length})
</h2>
<div className="flex gap-2">
<Button
size="sm"
variant={isPlacingMode ? 'danger' : 'primary'}
onClick={togglePlacingMode}
>
{isPlacingMode ? 'Cancel' : '+ Place on Map'}
</Button>
<Button size="sm" variant="secondary" onClick={onAddSite}>
+ Manual
</Button>
</div>
</div>
{sites.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-400">
No sites yet. Click on the map or use "+ Manual" to add one.
</div>
) : (
<div className="divide-y divide-gray-100 max-h-60 overflow-y-auto">
{sites.map((site) => (
<div
key={site.id}
className={`px-4 py-2 flex items-center gap-3 cursor-pointer hover:bg-gray-50
transition-colors ${selectedSiteId === site.id ? 'bg-blue-50' : ''}`}
onClick={() => selectSite(site.id)}
>
{/* Color indicator */}
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: site.color }}
/>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-800 truncate">
{site.name}
</div>
<div className="text-xs text-gray-400">
{site.frequency} MHz · {site.power} dBm · {site.antennaType}
</div>
</div>
{/* Actions */}
<div className="flex gap-1 flex-shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
onEditSite(site);
}}
className="px-2 py-1 text-xs text-blue-600 hover:bg-blue-50 rounded"
>
Edit
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(site.id, site.name);
}}
className="px-2 py-1 text-xs text-red-600 hover:bg-red-50 rounded"
>
×
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
children: ReactNode;
}
const variants = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white',
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-800',
danger: 'bg-red-600 hover:bg-red-700 text-white',
ghost: 'bg-transparent hover:bg-gray-100 text-gray-700',
};
const sizes = {
sm: 'px-2 py-1 text-xs',
md: 'px-3 py-1.5 text-sm',
lg: 'px-4 py-2 text-base',
};
export default function Button({
variant = 'primary',
size = 'md',
className = '',
children,
...props
}: ButtonProps) {
return (
<button
className={`inline-flex items-center justify-center rounded-md font-medium transition-colors
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1
disabled:opacity-50 disabled:cursor-not-allowed
${variants[variant]} ${sizes[size]} ${className}`}
{...props}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,21 @@
import type { InputHTMLAttributes } from 'react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
}
export default function Input({ label, className = '', ...props }: InputProps) {
return (
<div className="space-y-1">
{label && (
<label className="text-sm font-medium text-gray-700">{label}</label>
)}
<input
className={`w-full px-3 py-2 border border-gray-300 rounded-md text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
${className}`}
{...props}
/>
</div>
);
}

View File

@@ -0,0 +1,47 @@
interface SliderProps {
label: string;
value: number;
min: number;
max: number;
step?: number;
unit: string;
hint?: string;
onChange: (value: number) => void;
}
export default function Slider({
label,
value,
min,
max,
step = 1,
unit,
hint,
onChange,
}: SliderProps) {
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">{label}</label>
<span className="text-sm font-semibold text-blue-600">
{value} {unit}
</span>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer
accent-blue-600"
/>
<div className="flex justify-between text-xs text-gray-400">
<span>{min}</span>
<span>{max}</span>
</div>
{hint && <p className="text-xs text-gray-400">{hint}</p>}
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { useState, useEffect, useCallback } from 'react';
import { create } from 'zustand';
interface ToastMessage {
id: number;
text: string;
type: 'success' | 'error' | 'info';
}
interface ToastStore {
toasts: ToastMessage[];
addToast: (text: string, type?: 'success' | 'error' | 'info') => void;
removeToast: (id: number) => void;
}
let nextId = 0;
export const useToastStore = create<ToastStore>((set) => ({
toasts: [],
addToast: (text, type = 'info') => {
const id = nextId++;
set((state) => ({
toasts: [...state.toasts, { id, text, type }],
}));
setTimeout(() => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}));
}, 4000);
},
removeToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
})),
}));
function ToastItem({ toast }: { toast: ToastMessage }) {
const [visible, setVisible] = useState(false);
const removeToast = useToastStore((s) => s.removeToast);
useEffect(() => {
requestAnimationFrame(() => setVisible(true));
}, []);
const handleClose = useCallback(() => {
setVisible(false);
setTimeout(() => removeToast(toast.id), 200);
}, [toast.id, removeToast]);
const bgColor =
toast.type === 'success'
? 'bg-green-500'
: toast.type === 'error'
? 'bg-red-500'
: 'bg-blue-500';
return (
<div
className={`${bgColor} text-white px-4 py-2 rounded-lg shadow-lg text-sm
transition-all duration-200 ${visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'}`}
>
<div className="flex items-center gap-2">
<span className="flex-1">{toast.text}</span>
<button
onClick={handleClose}
className="text-white/80 hover:text-white"
>
×
</button>
</div>
</div>
);
}
export default function ToastContainer() {
const toasts = useToastStore((s) => s.toasts);
return (
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} />
))}
</div>
);
}

View File

@@ -0,0 +1,98 @@
import type { FrequencyBand } from '@/types/index.ts';
export const COMMON_FREQUENCIES: FrequencyBand[] = [
{
value: 800,
name: 'Band 20',
range: '791-862 MHz',
type: 'LTE',
characteristics: {
range: 'long',
penetration: 'excellent',
typical: 'Rural coverage, deep building penetration',
},
},
{
value: 1800,
name: 'Band 3',
range: '1710-1880 MHz',
type: 'LTE',
characteristics: {
range: 'medium',
penetration: 'good',
typical: 'Urban/suburban, most common in Ukraine',
},
},
{
value: 1900,
name: 'Band 2',
range: '1850-1990 MHz',
type: 'LTE',
characteristics: {
range: 'medium',
penetration: 'good',
typical: 'North America, some military equipment',
},
},
{
value: 2600,
name: 'Band 7',
range: '2500-2690 MHz',
type: 'LTE',
characteristics: {
range: 'short',
penetration: 'fair',
typical: 'High capacity urban, shorter range',
},
},
{
value: 150,
name: 'VHF High',
range: '136-174 MHz',
type: 'VHF',
characteristics: {
range: 'long',
penetration: 'excellent',
typical: 'Tactical radio, emergency services',
},
},
{
value: 450,
name: 'UHF',
range: '400-470 MHz',
type: 'UHF',
characteristics: {
range: 'medium',
penetration: 'good',
typical: 'Military tactical radio, PMR446',
},
},
{
value: 3500,
name: 'Band 42/43 (n78)',
range: '3400-3800 MHz',
type: '5G',
characteristics: {
range: 'short',
penetration: 'poor',
typical: '5G NR, high bandwidth',
},
},
];
export const QUICK_FREQUENCIES = [800, 1800, 1900, 2600];
export function getFrequencyInfo(frequency: number): FrequencyBand | null {
return (
COMMON_FREQUENCIES.find((band) => Math.abs(band.value - frequency) < 50) ||
null
);
}
export function getWavelength(frequencyMHz: number): string {
const wavelengthMeters = 300 / frequencyMHz;
if (wavelengthMeters >= 1) {
return `${wavelengthMeters.toFixed(2)} m`;
}
return `${(wavelengthMeters * 100).toFixed(1)} cm`;
}

View File

@@ -0,0 +1,40 @@
export const RSRP_THRESHOLDS = {
EXCELLENT: -70,
GOOD: -85,
FAIR: -100,
POOR: -110,
WEAK: -120,
NO_SERVICE: -120,
} as const;
export type SignalQuality = 'excellent' | 'good' | 'fair' | 'poor' | 'weak' | 'no-service';
export function getSignalQuality(rsrp: number): SignalQuality {
if (rsrp >= RSRP_THRESHOLDS.EXCELLENT) return 'excellent';
if (rsrp >= RSRP_THRESHOLDS.GOOD) return 'good';
if (rsrp >= RSRP_THRESHOLDS.FAIR) return 'fair';
if (rsrp >= RSRP_THRESHOLDS.POOR) return 'poor';
if (rsrp >= RSRP_THRESHOLDS.WEAK) return 'weak';
return 'no-service';
}
export const SIGNAL_COLORS: Record<string, string> = {
excellent: '#22c55e',
good: '#84cc16',
fair: '#eab308',
poor: '#f97316',
weak: '#ef4444',
'no-service': '#6b7280',
} as const;
export function getRSRPColor(rsrp: number): string {
return SIGNAL_COLORS[getSignalQuality(rsrp)] ?? '#6b7280';
}
export const RSRP_LEGEND = [
{ label: 'Excellent', range: '> -70 dBm', color: SIGNAL_COLORS.excellent, min: -70 },
{ label: 'Good', range: '-70 to -85 dBm', color: SIGNAL_COLORS.good, min: -85 },
{ label: 'Fair', range: '-85 to -100 dBm', color: SIGNAL_COLORS.fair, min: -100 },
{ label: 'Poor', range: '-100 to -110 dBm', color: SIGNAL_COLORS.poor, min: -110 },
{ label: 'Weak', range: '-110 to -120 dBm', color: SIGNAL_COLORS.weak, min: -120 },
] as const;

32
frontend/src/db/schema.ts Normal file
View File

@@ -0,0 +1,32 @@
import Dexie, { type Table } from 'dexie';
export interface DBSite {
id: string;
data: string; // JSON serialized Site
createdAt: number;
updatedAt: number;
}
export interface DBProjectConfig {
id: string;
name: string;
data: string; // JSON serialized ProjectConfig
createdAt: number;
updatedAt: number;
syncedAt?: number;
}
export class RFCPDatabase extends Dexie {
sites!: Table<DBSite, string>;
projects!: Table<DBProjectConfig, string>;
constructor() {
super('rfcp-db');
this.version(1).stores({
sites: 'id, updatedAt',
projects: 'id, updatedAt, syncedAt',
});
}
}
export const db = new RFCPDatabase();

28
frontend/src/index.css Normal file
View File

@@ -0,0 +1,28 @@
@import "tailwindcss";
@layer base {
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
height: 100%;
width: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
/* Leaflet overrides */
.leaflet-container {
width: 100%;
height: 100%;
z-index: 0;
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,20 @@
/**
* Calculate antenna pattern loss for sector antennas.
* Based on 3GPP antenna model.
*
* @param angleOffBoresight - Angle from antenna boresight (0-180 degrees)
* @param beamwidth - 3dB beamwidth in degrees
* @returns Pattern loss in dB
*/
export function calculateSectorPatternLoss(
angleOffBoresight: number,
beamwidth: number = 65
): number {
const theta3dB = beamwidth / 2;
const sideLobeLevel = 20; // dB
return Math.min(
12 * Math.pow(angleOffBoresight / theta3dB, 2),
sideLobeLevel
);
}

View File

@@ -0,0 +1,134 @@
import type { Site, CoveragePoint, CoverageResult, CoverageSettings, GridPoint } from '@/types/index.ts';
export class RFCalculator {
/**
* Calculate coverage for multiple sites using Web Workers.
*/
async calculateCoverage(
sites: Site[],
mapBounds: { north: number; south: number; east: number; west: number },
settings: CoverageSettings
): Promise<CoverageResult> {
const startTime = performance.now();
// Generate grid of points
const gridPoints = this.generateGrid(mapBounds, settings.resolution);
// Determine number of workers
const numWorkers = Math.min(4, navigator.hardwareConcurrency || 4);
const chunks = this.chunkArray(gridPoints, numWorkers);
// Serialize site data for workers
const sitesData = sites.map((s) => ({
id: s.id,
lat: s.lat,
lon: s.lon,
height: s.height,
power: s.power,
gain: s.gain,
frequency: s.frequency,
antennaType: s.antennaType,
azimuth: s.azimuth,
beamwidth: s.beamwidth,
}));
// Calculate in parallel using Web Workers
const workers: Worker[] = [];
const promises: Promise<CoveragePoint[]>[] = [];
for (let i = 0; i < chunks.length; i++) {
const worker = new Worker('/workers/rf-worker.js');
workers.push(worker);
promises.push(
this.calculateChunk(worker, sitesData, chunks[i], settings.rsrpThreshold)
);
}
const results = await Promise.all(promises);
// Cleanup workers
workers.forEach((w) => w.terminate());
// Merge results
const allPoints = results.flat();
const calculationTime = performance.now() - startTime;
return {
points: allPoints,
calculationTime,
totalPoints: allPoints.length,
settings,
};
}
private generateGrid(
bounds: { north: number; south: number; east: number; west: number },
resolution: number
): GridPoint[] {
const points: GridPoint[] = [];
// Convert resolution to degrees
const latStep = resolution / 111000; // ~111km per degree latitude
const centerLat = (bounds.north + bounds.south) / 2;
const lonStep =
resolution / (111000 * Math.cos((centerLat * Math.PI) / 180));
let lat = bounds.south;
while (lat <= bounds.north) {
let lon = bounds.west;
while (lon <= bounds.east) {
points.push({ lat, lon });
lon += lonStep;
}
lat += latStep;
}
return points;
}
private chunkArray<T>(array: T[], numChunks: number): T[][] {
const chunks: T[][] = [];
const chunkSize = Math.ceil(array.length / numChunks);
for (let i = 0; i < array.length; i += chunkSize) {
chunks.push(array.slice(i, i + chunkSize));
}
return chunks;
}
private calculateChunk(
worker: Worker,
sites: unknown[],
points: GridPoint[],
rsrpThreshold: number
): Promise<CoveragePoint[]> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Worker timeout'));
}, 30000);
worker.postMessage({
type: 'calculate',
sites,
points,
rsrpThreshold,
});
worker.onmessage = (e: MessageEvent) => {
clearTimeout(timeout);
if (e.data.type === 'complete') {
resolve(e.data.results);
} else if (e.data.type === 'error') {
reject(new Error(e.data.message));
}
};
worker.onerror = (err) => {
clearTimeout(timeout);
reject(err);
};
});
}
}

21
frontend/src/rf/fspl.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* Calculate Free Space Path Loss.
* Universal for all RF frequencies.
*
* FSPL(dB) = 20*log10(d_km) + 20*log10(f_MHz) + 32.45
*
* @param distanceKm - Distance in kilometers
* @param frequencyMHz - Frequency in megahertz
* @returns Path loss in dB
*/
export function calculateFSPL(
distanceKm: number,
frequencyMHz: number
): number {
if (distanceKm <= 0) return 0;
return (
20 * Math.log10(distanceKm) +
20 * Math.log10(frequencyMHz) +
32.45
);
}

47
frontend/src/rf/utils.ts Normal file
View File

@@ -0,0 +1,47 @@
/**
* Haversine distance between two lat/lon points.
* @returns Distance in kilometers
*/
export function haversineDistance(
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number {
const R = 6371; // Earth radius in km
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* Calculate bearing from point A to point B.
* @returns Bearing in degrees (0-360)
*/
export function calculateBearing(
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number {
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const lat1Rad = (lat1 * Math.PI) / 180;
const lat2Rad = (lat2 * Math.PI) / 180;
const y = Math.sin(dLon) * Math.cos(lat2Rad);
const x =
Math.cos(lat1Rad) * Math.sin(lat2Rad) -
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon);
const bearing = (Math.atan2(y, x) * 180) / Math.PI;
return (bearing + 360) % 360;
}

View File

@@ -0,0 +1,35 @@
import { create } from 'zustand';
import type { CoverageResult, CoverageSettings } from '@/types/index.ts';
interface CoverageState {
result: CoverageResult | null;
isCalculating: boolean;
settings: CoverageSettings;
heatmapVisible: boolean;
setResult: (result: CoverageResult | null) => void;
setIsCalculating: (val: boolean) => void;
updateSettings: (settings: Partial<CoverageSettings>) => void;
toggleHeatmap: () => void;
setHeatmapVisible: (val: boolean) => void;
}
export const useCoverageStore = create<CoverageState>((set) => ({
result: null,
isCalculating: false,
settings: {
radius: 5,
resolution: 200,
rsrpThreshold: -120,
},
heatmapVisible: true,
setResult: (result) => set({ result }),
setIsCalculating: (val) => set({ isCalculating: val }),
updateSettings: (newSettings) =>
set((state) => ({
settings: { ...state.settings, ...newSettings },
})),
toggleHeatmap: () => set((s) => ({ heatmapVisible: !s.heatmapVisible })),
setHeatmapVisible: (val) => set({ heatmapVisible: val }),
}));

View File

@@ -0,0 +1,99 @@
import { create } from 'zustand';
import { v4 as uuidv4 } from 'uuid';
import type { Site, SiteFormData } from '@/types/index.ts';
import { db } from '@/db/schema.ts';
const SITE_COLORS = [
'#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6',
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16',
];
interface SitesState {
sites: Site[];
selectedSiteId: string | null;
editingSiteId: string | null;
isPlacingMode: boolean;
loadSites: () => Promise<void>;
addSite: (data: SiteFormData) => Promise<Site>;
updateSite: (id: string, data: Partial<Site>) => Promise<void>;
deleteSite: (id: string) => Promise<void>;
selectSite: (id: string | null) => void;
setEditingSite: (id: string | null) => void;
togglePlacingMode: () => void;
setPlacingMode: (val: boolean) => void;
}
export const useSitesStore = create<SitesState>((set, get) => ({
sites: [],
selectedSiteId: null,
editingSiteId: null,
isPlacingMode: false,
loadSites: async () => {
const dbSites = await db.sites.toArray();
const sites: Site[] = dbSites.map((s) => ({
...JSON.parse(s.data),
createdAt: new Date(s.createdAt),
updatedAt: new Date(s.updatedAt),
}));
set({ sites });
},
addSite: async (data: SiteFormData) => {
const id = uuidv4();
const now = new Date();
const colorIndex = get().sites.length % SITE_COLORS.length;
const site: Site = {
...data,
id,
color: data.color || SITE_COLORS[colorIndex],
visible: true,
createdAt: now,
updatedAt: now,
};
await db.sites.put({
id,
data: JSON.stringify(site),
createdAt: now.getTime(),
updatedAt: now.getTime(),
});
set((state) => ({ sites: [...state.sites, site] }));
return site;
},
updateSite: async (id: string, data: Partial<Site>) => {
const sites = get().sites;
const existing = sites.find((s) => s.id === id);
if (!existing) return;
const updated: Site = {
...existing,
...data,
updatedAt: new Date(),
};
await db.sites.put({
id,
data: JSON.stringify(updated),
createdAt: existing.createdAt.getTime(),
updatedAt: updated.updatedAt.getTime(),
});
set((state) => ({
sites: state.sites.map((s) => (s.id === id ? updated : s)),
}));
},
deleteSite: async (id: string) => {
await db.sites.delete(id);
set((state) => ({
sites: state.sites.filter((s) => s.id !== id),
selectedSiteId: state.selectedSiteId === id ? null : state.selectedSiteId,
editingSiteId: state.editingSiteId === id ? null : state.editingSiteId,
}));
},
selectSite: (id: string | null) => set({ selectedSiteId: id }),
setEditingSite: (id: string | null) => set({ editingSiteId: id }),
togglePlacingMode: () => set((s) => ({ isPlacingMode: !s.isPlacingMode })),
setPlacingMode: (val: boolean) => set({ isPlacingMode: val }),
}));

View File

@@ -0,0 +1,24 @@
export interface CoveragePoint {
lat: number;
lon: number;
rsrp: number; // dBm (calculated signal strength)
siteId: string; // which site provides this coverage
}
export interface CoverageResult {
points: CoveragePoint[];
calculationTime: number; // milliseconds
totalPoints: number;
settings: CoverageSettings;
}
export interface CoverageSettings {
radius: number; // km (calculation radius)
resolution: number; // meters (grid resolution)
rsrpThreshold: number; // dBm (minimum signal to display)
}
export interface GridPoint {
lat: number;
lon: number;
}

View File

@@ -0,0 +1,11 @@
export interface FrequencyBand {
value: number; // MHz
name: string; // e.g., "Band 3"
range: string; // e.g., "1710-1880 MHz"
type: 'LTE' | 'UHF' | 'VHF' | '5G' | 'Custom';
characteristics: {
range: 'short' | 'medium' | 'long';
penetration: 'poor' | 'fair' | 'good' | 'excellent';
typical: string;
};
}

View File

@@ -0,0 +1,8 @@
export type { Site, SiteFormData } from './site.ts';
export type {
CoveragePoint,
CoverageResult,
CoverageSettings,
GridPoint,
} from './coverage.ts';
export type { FrequencyBand } from './frequency.ts';

View File

@@ -0,0 +1,21 @@
export interface Site {
id: string;
name: string;
lat: number;
lon: number;
height: number; // meters above ground (1-100)
power: number; // dBm (10-50)
gain: number; // dBi (0-25)
frequency: number; // MHz (any value)
antennaType: 'omni' | 'sector';
azimuth?: number; // degrees (0-360), sector only
beamwidth?: number; // degrees (30-120), sector only
color: string; // hex color for map marker
visible: boolean;
notes?: string;
equipment?: string;
createdAt: Date;
updatedAt: Date;
}
export type SiteFormData = Omit<Site, 'id' | 'createdAt' | 'updatedAt'>;

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

13
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "rfcp",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}