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