Compare commits
2 Commits
343c8e078d
...
f5efff77a7
| Author | SHA1 | Date | |
|---|---|---|---|
| f5efff77a7 | |||
| 18a7d6de81 |
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal 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
251
RFCP-Fixes-Iteration1.md
Normal 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
24
frontend/.gitignore
vendored
Normal 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
73
frontend/README.md
Normal 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
23
frontend/eslint.config.js
Normal 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
16
frontend/index.html
Normal 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
3726
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
18
frontend/public/manifest.json
Normal file
18
frontend/public/manifest.json
Normal 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
1
frontend/public/vite.svg
Normal 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 |
123
frontend/public/workers/rf-worker.js
Normal file
123
frontend/public/workers/rf-worker.js
Normal 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
270
frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
frontend/src/components/map/Heatmap.tsx
Normal file
67
frontend/src/components/map/Heatmap.tsx
Normal 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;
|
||||
}
|
||||
52
frontend/src/components/map/Legend.tsx
Normal file
52
frontend/src/components/map/Legend.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/map/Map.tsx
Normal file
54
frontend/src/components/map/Map.tsx
Normal 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='© <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>
|
||||
);
|
||||
}
|
||||
88
frontend/src/components/map/SiteMarker.tsx
Normal file
88
frontend/src/components/map/SiteMarker.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
102
frontend/src/components/panels/FrequencySelector.tsx
Normal file
102
frontend/src/components/panels/FrequencySelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
324
frontend/src/components/panels/SiteForm.tsx
Normal file
324
frontend/src/components/panels/SiteForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
frontend/src/components/panels/SiteList.tsx
Normal file
101
frontend/src/components/panels/SiteList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
frontend/src/components/ui/Button.tsx
Normal file
40
frontend/src/components/ui/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/Input.tsx
Normal file
21
frontend/src/components/ui/Input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
frontend/src/components/ui/Slider.tsx
Normal file
47
frontend/src/components/ui/Slider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
frontend/src/components/ui/Toast.tsx
Normal file
85
frontend/src/components/ui/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
frontend/src/constants/frequencies.ts
Normal file
98
frontend/src/constants/frequencies.ts
Normal 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`;
|
||||
}
|
||||
40
frontend/src/constants/rsrp-thresholds.ts
Normal file
40
frontend/src/constants/rsrp-thresholds.ts
Normal 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
32
frontend/src/db/schema.ts
Normal 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
28
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
20
frontend/src/rf/antenna-pattern.ts
Normal file
20
frontend/src/rf/antenna-pattern.ts
Normal 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
|
||||
);
|
||||
}
|
||||
134
frontend/src/rf/calculator.ts
Normal file
134
frontend/src/rf/calculator.ts
Normal 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
21
frontend/src/rf/fspl.ts
Normal 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
47
frontend/src/rf/utils.ts
Normal 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;
|
||||
}
|
||||
35
frontend/src/store/coverage.ts
Normal file
35
frontend/src/store/coverage.ts
Normal 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 }),
|
||||
}));
|
||||
99
frontend/src/store/sites.ts
Normal file
99
frontend/src/store/sites.ts
Normal 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 }),
|
||||
}));
|
||||
24
frontend/src/types/coverage.ts
Normal file
24
frontend/src/types/coverage.ts
Normal 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;
|
||||
}
|
||||
11
frontend/src/types/frequency.ts
Normal file
11
frontend/src/types/frequency.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
8
frontend/src/types/index.ts
Normal file
8
frontend/src/types/index.ts
Normal 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';
|
||||
21
frontend/src/types/site.ts
Normal file
21
frontend/src/types/site.ts
Normal 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'>;
|
||||
34
frontend/tsconfig.app.json
Normal file
34
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
13
frontend/vite.config.ts
Normal 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
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "rfcp",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Reference in New Issue
Block a user