@mytec: iter3.5.0 ready for testing
This commit is contained in:
35
backend/app/api/routes/gpu.py
Normal file
35
backend/app/api/routes/gpu.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""GPU management API endpoints."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services.gpu_backend import gpu_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SetDeviceRequest(BaseModel):
|
||||
backend: str
|
||||
index: int = 0
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def gpu_status():
|
||||
"""Return GPU manager status: active backend, device, available devices."""
|
||||
return gpu_manager.get_status()
|
||||
|
||||
|
||||
@router.get("/devices")
|
||||
async def gpu_devices():
|
||||
"""Return list of available compute devices."""
|
||||
return {"devices": gpu_manager.get_devices()}
|
||||
|
||||
|
||||
@router.post("/set")
|
||||
async def gpu_set_device(request: SetDeviceRequest):
|
||||
"""Switch active compute device."""
|
||||
try:
|
||||
result = gpu_manager.set_device(request.backend, request.index)
|
||||
return {"status": "ok", **result}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -4,7 +4,7 @@ from fastapi import FastAPI, WebSocket
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.database import connect_to_mongo, close_mongo_connection
|
||||
from app.api.routes import health, projects, terrain, coverage, regions, system
|
||||
from app.api.routes import health, projects, terrain, coverage, regions, system, gpu
|
||||
from app.api.websocket import websocket_endpoint
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ app.include_router(terrain.router, prefix="/api/terrain", tags=["terrain"])
|
||||
app.include_router(coverage.router, prefix="/api/coverage", tags=["coverage"])
|
||||
app.include_router(regions.router, prefix="/api/regions", tags=["regions"])
|
||||
app.include_router(system.router, prefix="/api/system", tags=["system"])
|
||||
app.include_router(gpu.router, prefix="/api/gpu", tags=["gpu"])
|
||||
|
||||
# WebSocket endpoint for real-time coverage with progress
|
||||
app.websocket("/ws")(websocket_endpoint)
|
||||
|
||||
@@ -247,6 +247,9 @@ class CoverageSettings(BaseModel):
|
||||
temperature_c: float = 15.0
|
||||
humidity_percent: float = 50.0
|
||||
|
||||
# Fading margin (dB) — additional safety loss subtracted from RSRP
|
||||
fading_margin: float = 0.0
|
||||
|
||||
# Preset
|
||||
preset: Optional[str] = None # fast, standard, detailed, full
|
||||
|
||||
@@ -1362,7 +1365,8 @@ class CoverageService:
|
||||
rsrp = (site.power + site.gain - path_loss - antenna_loss
|
||||
- terrain_loss - building_loss - veg_loss
|
||||
- rain_loss - indoor_loss - atmo_loss
|
||||
+ reflection_gain)
|
||||
+ reflection_gain
|
||||
- settings.fading_margin)
|
||||
|
||||
return CoveragePoint(
|
||||
lat=lat, lon=lon, rsrp=rsrp, distance=distance,
|
||||
@@ -1508,7 +1512,8 @@ class CoverageService:
|
||||
)
|
||||
|
||||
rsrp = (site.power + site.gain - path_loss
|
||||
- antenna_loss - terrain_loss)
|
||||
- antenna_loss - terrain_loss
|
||||
- settings.fading_margin)
|
||||
|
||||
if rsrp >= settings.min_signal:
|
||||
points.append(CoveragePoint(
|
||||
|
||||
192
backend/app/services/gpu_backend.py
Normal file
192
backend/app/services/gpu_backend.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
GPU Backend Manager — detects and manages compute backends.
|
||||
|
||||
Supports:
|
||||
- CUDA via CuPy
|
||||
- OpenCL via PyOpenCL (future)
|
||||
- CPU via NumPy (always available)
|
||||
|
||||
Usage:
|
||||
from app.services.gpu_backend import gpu_manager
|
||||
xp = gpu_manager.get_array_module() # cupy or numpy
|
||||
status = gpu_manager.get_status()
|
||||
"""
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GPUBackend(str, Enum):
|
||||
CUDA = "cuda"
|
||||
OPENCL = "opencl"
|
||||
CPU = "cpu"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GPUDevice:
|
||||
backend: GPUBackend
|
||||
index: int
|
||||
name: str
|
||||
memory_mb: int
|
||||
extra: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class GPUManager:
|
||||
"""Singleton GPU manager with device detection and selection."""
|
||||
|
||||
def __init__(self):
|
||||
self._devices: list[GPUDevice] = []
|
||||
self._active_backend: GPUBackend = GPUBackend.CPU
|
||||
self._active_device: Optional[GPUDevice] = None
|
||||
self._cupy = None
|
||||
self._detect_devices()
|
||||
|
||||
def _detect_devices(self):
|
||||
"""Probe available GPU backends."""
|
||||
# Always add CPU
|
||||
cpu_device = GPUDevice(
|
||||
backend=GPUBackend.CPU,
|
||||
index=0,
|
||||
name="CPU (NumPy)",
|
||||
memory_mb=0,
|
||||
)
|
||||
self._devices.append(cpu_device)
|
||||
|
||||
# Try CuPy / CUDA
|
||||
try:
|
||||
import cupy as cp
|
||||
device_count = cp.cuda.runtime.getDeviceCount()
|
||||
for i in range(device_count):
|
||||
props = cp.cuda.runtime.getDeviceProperties(i)
|
||||
name = props["name"]
|
||||
if isinstance(name, bytes):
|
||||
name = name.decode()
|
||||
mem_mb = props["totalGlobalMem"] // (1024 * 1024)
|
||||
cuda_ver = cp.cuda.runtime.runtimeGetVersion()
|
||||
device = GPUDevice(
|
||||
backend=GPUBackend.CUDA,
|
||||
index=i,
|
||||
name=str(name),
|
||||
memory_mb=mem_mb,
|
||||
extra={"cuda_version": cuda_ver},
|
||||
)
|
||||
self._devices.append(device)
|
||||
logger.info(f"[GPU] CUDA device {i}: {name} ({mem_mb} MB)")
|
||||
if device_count > 0:
|
||||
self._cupy = cp
|
||||
except ImportError:
|
||||
logger.info("[GPU] CuPy not installed — CUDA unavailable")
|
||||
except Exception as e:
|
||||
logger.warning(f"[GPU] CuPy probe error: {e}")
|
||||
|
||||
# Try PyOpenCL (future — stub for detection only)
|
||||
try:
|
||||
import pyopencl as cl
|
||||
platforms = cl.get_platforms()
|
||||
for plat in platforms:
|
||||
for dev in plat.get_devices():
|
||||
mem_mb = dev.global_mem_size // (1024 * 1024)
|
||||
device = GPUDevice(
|
||||
backend=GPUBackend.OPENCL,
|
||||
index=len([d for d in self._devices if d.backend == GPUBackend.OPENCL]),
|
||||
name=dev.name.strip(),
|
||||
memory_mb=mem_mb,
|
||||
extra={"platform": plat.name.strip()},
|
||||
)
|
||||
self._devices.append(device)
|
||||
logger.info(f"[GPU] OpenCL device: {device.name} ({mem_mb} MB)")
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"[GPU] OpenCL probe error: {e}")
|
||||
|
||||
# Auto-select best: prefer CUDA > OpenCL > CPU
|
||||
cuda_devices = [d for d in self._devices if d.backend == GPUBackend.CUDA]
|
||||
if cuda_devices:
|
||||
self._active_backend = GPUBackend.CUDA
|
||||
self._active_device = cuda_devices[0]
|
||||
logger.info(f"[GPU] Active backend: CUDA — {self._active_device.name}")
|
||||
else:
|
||||
self._active_backend = GPUBackend.CPU
|
||||
self._active_device = cpu_device
|
||||
logger.info("[GPU] Active backend: CPU (NumPy)")
|
||||
|
||||
@property
|
||||
def gpu_available(self) -> bool:
|
||||
return self._active_backend != GPUBackend.CPU
|
||||
|
||||
def get_array_module(self) -> Any:
|
||||
"""Return cupy (if CUDA active) or numpy."""
|
||||
if self._active_backend == GPUBackend.CUDA and self._cupy is not None:
|
||||
return self._cupy
|
||||
return np
|
||||
|
||||
def to_cpu(self, arr: Any) -> np.ndarray:
|
||||
"""Transfer array to CPU numpy."""
|
||||
if hasattr(arr, 'get'):
|
||||
return arr.get()
|
||||
return np.asarray(arr)
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Full status dict for API."""
|
||||
return {
|
||||
"active_backend": self._active_backend.value,
|
||||
"active_device": {
|
||||
"backend": self._active_device.backend.value,
|
||||
"index": self._active_device.index,
|
||||
"name": self._active_device.name,
|
||||
"memory_mb": self._active_device.memory_mb,
|
||||
} if self._active_device else None,
|
||||
"gpu_available": self.gpu_available,
|
||||
"available_devices": [
|
||||
{
|
||||
"backend": d.backend.value,
|
||||
"index": d.index,
|
||||
"name": d.name,
|
||||
"memory_mb": d.memory_mb,
|
||||
}
|
||||
for d in self._devices
|
||||
],
|
||||
}
|
||||
|
||||
def get_devices(self) -> list[dict]:
|
||||
"""Device list for API."""
|
||||
return [
|
||||
{
|
||||
"backend": d.backend.value,
|
||||
"index": d.index,
|
||||
"name": d.name,
|
||||
"memory_mb": d.memory_mb,
|
||||
}
|
||||
for d in self._devices
|
||||
]
|
||||
|
||||
def set_device(self, backend: str, index: int = 0) -> dict:
|
||||
"""Switch active compute device."""
|
||||
target_backend = GPUBackend(backend)
|
||||
candidates = [d for d in self._devices
|
||||
if d.backend == target_backend and d.index == index]
|
||||
if not candidates:
|
||||
raise ValueError(f"No device found: backend={backend}, index={index}")
|
||||
|
||||
self._active_device = candidates[0]
|
||||
self._active_backend = target_backend
|
||||
|
||||
if target_backend == GPUBackend.CUDA and self._cupy is not None:
|
||||
self._cupy.cuda.Device(index).use()
|
||||
|
||||
logger.info(f"[GPU] Switched to: {self._active_device.name} ({target_backend.value})")
|
||||
return {
|
||||
"backend": self._active_backend.value,
|
||||
"device": self._active_device.name,
|
||||
}
|
||||
|
||||
|
||||
# Singleton
|
||||
gpu_manager = GPUManager()
|
||||
@@ -3,7 +3,7 @@ GPU-accelerated computation service using CuPy.
|
||||
Falls back to NumPy when CuPy/CUDA is not available.
|
||||
|
||||
Provides vectorized batch operations for coverage calculation:
|
||||
- Haversine distance (site → all grid points)
|
||||
- Haversine distance (site -> all grid points)
|
||||
- Okumura-Hata path loss (all distances at once)
|
||||
|
||||
Usage:
|
||||
@@ -11,48 +11,29 @@ Usage:
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Dict, Any
|
||||
|
||||
# ── Try CuPy import ──
|
||||
|
||||
GPU_AVAILABLE = False
|
||||
GPU_INFO: Optional[Dict[str, Any]] = None
|
||||
cp = None
|
||||
|
||||
try:
|
||||
import cupy as _cp
|
||||
device_count = _cp.cuda.runtime.getDeviceCount()
|
||||
if device_count > 0:
|
||||
cp = _cp
|
||||
GPU_AVAILABLE = True
|
||||
props = _cp.cuda.runtime.getDeviceProperties(0)
|
||||
GPU_INFO = {
|
||||
"name": props["name"].decode() if isinstance(props["name"], bytes) else str(props["name"]),
|
||||
"memory_mb": props["totalGlobalMem"] // (1024 * 1024),
|
||||
"cuda_version": _cp.cuda.runtime.runtimeGetVersion(),
|
||||
}
|
||||
print(f"[GPU] CUDA available: {GPU_INFO['name']} ({GPU_INFO['memory_mb']} MB)", flush=True)
|
||||
else:
|
||||
print("[GPU] No CUDA devices found", flush=True)
|
||||
except ImportError:
|
||||
print("[GPU] CuPy not installed — using CPU/NumPy", flush=True)
|
||||
print("[GPU] To enable GPU acceleration, install CuPy:", flush=True)
|
||||
print("[GPU] For CUDA 12.x: pip install cupy-cuda12x", flush=True)
|
||||
print("[GPU] For CUDA 11.x: pip install cupy-cuda11x", flush=True)
|
||||
print("[GPU] Check CUDA version: nvidia-smi", flush=True)
|
||||
except Exception as e:
|
||||
print(f"[GPU] CuPy error: {e} — GPU acceleration disabled", flush=True)
|
||||
from app.services.gpu_backend import gpu_manager
|
||||
|
||||
# Backward-compatible exports
|
||||
GPU_AVAILABLE = gpu_manager.gpu_available
|
||||
GPU_INFO: Dict[str, Any] | None = (
|
||||
{
|
||||
"name": gpu_manager._active_device.name,
|
||||
"memory_mb": gpu_manager._active_device.memory_mb,
|
||||
**gpu_manager._active_device.extra,
|
||||
}
|
||||
if gpu_manager.gpu_available and gpu_manager._active_device
|
||||
else None
|
||||
)
|
||||
|
||||
# Array module: cupy on GPU, numpy on CPU
|
||||
xp = cp if GPU_AVAILABLE else np
|
||||
xp = gpu_manager.get_array_module()
|
||||
|
||||
|
||||
def _to_cpu(arr):
|
||||
"""Transfer array to CPU numpy if on GPU."""
|
||||
if GPU_AVAILABLE and hasattr(arr, 'get'):
|
||||
return arr.get()
|
||||
return np.asarray(arr)
|
||||
return gpu_manager.to_cpu(arr)
|
||||
|
||||
|
||||
class GPUService:
|
||||
@@ -60,13 +41,13 @@ class GPUService:
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return GPU_AVAILABLE
|
||||
return gpu_manager.gpu_available
|
||||
|
||||
def get_info(self) -> Dict[str, Any]:
|
||||
"""Return GPU info dict for system endpoint."""
|
||||
if not GPU_AVAILABLE:
|
||||
if not gpu_manager.gpu_available:
|
||||
return {"available": False, "name": None, "memory_mb": None}
|
||||
return {"available": True, **GPU_INFO}
|
||||
return {"available": True, **(GPU_INFO or {})}
|
||||
|
||||
def precompute_distances(
|
||||
self,
|
||||
@@ -79,16 +60,17 @@ class GPUService:
|
||||
|
||||
Returns distances in meters as a CPU numpy array.
|
||||
"""
|
||||
lat1 = xp.radians(xp.asarray(grid_lats, dtype=xp.float64))
|
||||
lon1 = xp.radians(xp.asarray(grid_lons, dtype=xp.float64))
|
||||
lat2 = xp.radians(xp.float64(site_lat))
|
||||
lon2 = xp.radians(xp.float64(site_lon))
|
||||
_xp = gpu_manager.get_array_module()
|
||||
lat1 = _xp.radians(_xp.asarray(grid_lats, dtype=_xp.float64))
|
||||
lon1 = _xp.radians(_xp.asarray(grid_lons, dtype=_xp.float64))
|
||||
lat2 = _xp.radians(_xp.float64(site_lat))
|
||||
lon2 = _xp.radians(_xp.float64(site_lon))
|
||||
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
|
||||
a = xp.sin(dlat / 2) ** 2 + xp.cos(lat1) * xp.cos(lat2) * xp.sin(dlon / 2) ** 2
|
||||
c = 2 * xp.arcsin(xp.sqrt(a))
|
||||
a = _xp.sin(dlat / 2) ** 2 + _xp.cos(lat1) * _xp.cos(lat2) * _xp.sin(dlon / 2) ** 2
|
||||
c = 2 * _xp.arcsin(_xp.sqrt(a))
|
||||
|
||||
distances = 6371000.0 * c
|
||||
return _to_cpu(distances)
|
||||
@@ -108,40 +90,41 @@ class GPUService:
|
||||
|
||||
Returns path loss in dB as a CPU numpy array.
|
||||
"""
|
||||
d_arr = xp.asarray(distances, dtype=xp.float64)
|
||||
d_km = xp.maximum(d_arr / 1000.0, 0.1)
|
||||
_xp = gpu_manager.get_array_module()
|
||||
d_arr = _xp.asarray(distances, dtype=_xp.float64)
|
||||
d_km = _xp.maximum(d_arr / 1000.0, 0.1)
|
||||
|
||||
freq = float(frequency_mhz)
|
||||
h_tx = max(float(tx_height), 1.0)
|
||||
h_rx = max(float(rx_height), 1.0)
|
||||
|
||||
log_f = xp.log10(xp.float64(freq))
|
||||
log_hb = xp.log10(xp.float64(max(h_tx, 1.0)))
|
||||
log_f = _xp.log10(_xp.float64(freq))
|
||||
log_hb = _xp.log10(_xp.float64(max(h_tx, 1.0)))
|
||||
|
||||
if freq > 2000:
|
||||
# Free-Space Path Loss: FSPL = 20*log10(d_km) + 20*log10(f) + 32.45
|
||||
L = 20.0 * xp.log10(d_km) + 20.0 * log_f + 32.45
|
||||
L = 20.0 * _xp.log10(d_km) + 20.0 * log_f + 32.45
|
||||
|
||||
elif freq > 1500:
|
||||
# COST-231 Hata: extends Okumura-Hata to 1500-2000 MHz
|
||||
a_hm = (1.1 * log_f - 0.7) * h_rx - (1.56 * log_f - 0.8)
|
||||
L = (46.3 + 33.9 * log_f - 13.82 * log_hb - a_hm
|
||||
+ (44.9 - 6.55 * log_hb) * xp.log10(d_km))
|
||||
+ (44.9 - 6.55 * log_hb) * _xp.log10(d_km))
|
||||
if environment == "urban":
|
||||
L += 3.0 # Metropolitan center correction
|
||||
|
||||
elif freq >= 150:
|
||||
# Okumura-Hata: 150-1500 MHz
|
||||
if environment == "urban" and freq >= 400:
|
||||
a_hm = 3.2 * (xp.log10(11.75 * h_rx) ** 2) - 4.97
|
||||
a_hm = 3.2 * (_xp.log10(11.75 * h_rx) ** 2) - 4.97
|
||||
else:
|
||||
a_hm = (1.1 * log_f - 0.7) * h_rx - (1.56 * log_f - 0.8)
|
||||
|
||||
L_urban = (69.55 + 26.16 * log_f - 13.82 * log_hb - a_hm
|
||||
+ (44.9 - 6.55 * log_hb) * xp.log10(d_km))
|
||||
+ (44.9 - 6.55 * log_hb) * _xp.log10(d_km))
|
||||
|
||||
if environment == "suburban":
|
||||
L = L_urban - 2 * (xp.log10(freq / 28) ** 2) - 5.4
|
||||
L = L_urban - 2 * (_xp.log10(freq / 28) ** 2) - 5.4
|
||||
elif environment == "rural":
|
||||
L = L_urban - 4.78 * (log_f ** 2) + 18.33 * log_f - 35.94
|
||||
elif environment == "open":
|
||||
@@ -152,7 +135,7 @@ class GPUService:
|
||||
else:
|
||||
# Very low frequency — Longley-Rice simplified (area mode)
|
||||
# Use FSPL as baseline with terrain roughness correction
|
||||
L = 20.0 * xp.log10(d_km) + 20.0 * log_f + 32.45 + 10.0
|
||||
L = 20.0 * _xp.log10(d_km) + 20.0 * log_f + 32.45 + 10.0
|
||||
|
||||
return _to_cpu(L)
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ import { SiteConfigModal } from '@/components/modals/index.ts';
|
||||
import type { SiteFormValues } from '@/components/modals/index.ts';
|
||||
import ToastContainer from '@/components/ui/Toast.tsx';
|
||||
import ThemeToggle from '@/components/ui/ThemeToggle.tsx';
|
||||
import GPUIndicator from '@/components/ui/GPUIndicator.tsx';
|
||||
import TerrainProfile from '@/components/map/TerrainProfile.tsx';
|
||||
import Button from '@/components/ui/Button.tsx';
|
||||
import NumberInput from '@/components/ui/NumberInput.tsx';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx';
|
||||
@@ -111,6 +113,7 @@ export default function App() {
|
||||
const setMeasurementMode = useSettingsStore((s) => s.setMeasurementMode);
|
||||
const showElevationInfo = useSettingsStore((s) => s.showElevationInfo);
|
||||
const setShowElevationInfo = useSettingsStore((s) => s.setShowElevationInfo);
|
||||
const showBoundary = useSettingsStore((s) => s.showBoundary);
|
||||
const showElevationOverlay = useSettingsStore((s) => s.showElevationOverlay);
|
||||
const setShowElevationOverlay = useSettingsStore((s) => s.setShowElevationOverlay);
|
||||
const elevationOpacity = useSettingsStore((s) => s.elevationOpacity);
|
||||
@@ -132,6 +135,7 @@ export default function App() {
|
||||
const [panelCollapsed, setPanelCollapsed] = useState(false);
|
||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||
const [kbDeleteTarget, setKbDeleteTarget] = useState<{ id: string; name: string } | null>(null);
|
||||
const [profileEndpoints, setProfileEndpoints] = useState<{ start: [number, number]; end: [number, number] } | null>(null);
|
||||
|
||||
// Region wizard for first-run (desktop mode only)
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
@@ -484,6 +488,7 @@ export default function App() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mr-4">
|
||||
<GPUIndicator />
|
||||
<ThemeToggle />
|
||||
{/* Undo / Redo buttons */}
|
||||
<div className="hidden sm:flex items-center gap-1">
|
||||
@@ -658,7 +663,11 @@ export default function App() {
|
||||
<div className="flex-1 flex overflow-hidden relative">
|
||||
{/* Map */}
|
||||
<div className="flex-1 relative">
|
||||
<MapView onMapClick={handleMapClick} onEditSite={handleEditSite}>
|
||||
<MapView
|
||||
onMapClick={handleMapClick}
|
||||
onEditSite={handleEditSite}
|
||||
onProfileRequest={(start, end) => setProfileEndpoints({ start, end })}
|
||||
>
|
||||
{/* Show partial results during tiled calculation, or final result */}
|
||||
{(coverageResult || (isCalculating && partialPoints.length > 0)) && (
|
||||
<>
|
||||
@@ -672,7 +681,7 @@ export default function App() {
|
||||
{coverageResult && (
|
||||
<CoverageBoundary
|
||||
points={coverageResult.points.filter(p => p.rsrp >= settings.rsrpThreshold)}
|
||||
visible={heatmapVisible}
|
||||
visible={showBoundary}
|
||||
resolution={settings.resolution}
|
||||
/>
|
||||
)}
|
||||
@@ -681,6 +690,13 @@ export default function App() {
|
||||
</MapView>
|
||||
<HeatmapLegend />
|
||||
<ResultsPanel />
|
||||
{profileEndpoints && (
|
||||
<TerrainProfile
|
||||
start={profileEndpoints.start}
|
||||
end={profileEndpoints.end}
|
||||
onClose={() => setProfileEndpoints(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Side panel */}
|
||||
@@ -1023,6 +1039,20 @@ export default function App() {
|
||||
<option value="vehicle">Inside Vehicle</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Fading margin */}
|
||||
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-dark-border">
|
||||
<NumberInput
|
||||
label="Fading Margin"
|
||||
value={settings.fading_margin ?? 0}
|
||||
onChange={(v) => useCoverageStore.getState().updateSettings({ fading_margin: v })}
|
||||
min={0}
|
||||
max={20}
|
||||
step={1}
|
||||
unit="dB"
|
||||
hint="Safety margin subtracted from signal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { normalizeRSRP, valueToColor } from '@/utils/colorGradient.ts';
|
||||
import { useCoverageStore } from '@/store/coverage.ts';
|
||||
import { useSitesStore } from '@/store/sites.ts';
|
||||
import { useSettingsStore } from '@/store/settings.ts';
|
||||
|
||||
const LEGEND_STEPS = [
|
||||
{ rsrp: -130, label: 'No Service' },
|
||||
@@ -41,6 +42,8 @@ export default function HeatmapLegend() {
|
||||
const toggleHeatmap = useCoverageStore((s) => s.toggleHeatmap);
|
||||
const settings = useCoverageStore((s) => s.settings);
|
||||
const sites = useSitesStore((s) => s.sites);
|
||||
const showBoundary = useSettingsStore((s) => s.showBoundary);
|
||||
const setShowBoundary = useSettingsStore((s) => s.setShowBoundary);
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
@@ -72,6 +75,23 @@ export default function HeatmapLegend() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Boundary toggle */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] text-gray-500 dark:text-dark-muted">
|
||||
Boundary
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowBoundary(!showBoundary)}
|
||||
className={`w-8 h-4 rounded-full transition-colors relative
|
||||
${showBoundary ? 'bg-blue-500' : 'bg-gray-300 dark:bg-dark-border'}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-3 h-3 rounded-full bg-white shadow transition-transform
|
||||
${showBoundary ? 'left-4' : 'left-0.5'}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Gradient bar + labels */}
|
||||
<div className="flex gap-2">
|
||||
{/* Continuous gradient bar */}
|
||||
|
||||
@@ -16,6 +16,7 @@ import ElevationLayer from './ElevationLayer.tsx';
|
||||
interface MapViewProps {
|
||||
onMapClick: (lat: number, lon: number) => void;
|
||||
onEditSite: (site: Site) => void;
|
||||
onProfileRequest?: (start: [number, number], end: [number, number]) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -48,7 +49,7 @@ function MapRefSetter({ mapRef }: { mapRef: React.MutableRefObject<LeafletMap |
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function MapView({ onMapClick, onEditSite, children }: MapViewProps) {
|
||||
export default function MapView({ onMapClick, onEditSite, onProfileRequest, children }: MapViewProps) {
|
||||
const sites = useSitesStore((s) => s.sites);
|
||||
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
|
||||
const showTerrain = useSettingsStore((s) => s.showTerrain);
|
||||
@@ -109,6 +110,7 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
|
||||
addToast(`Distance: ${distKm.toFixed(2)} km (${(distKm * 1000).toFixed(0)} m)`, 'info');
|
||||
setMeasurementMode(false);
|
||||
}}
|
||||
onProfileRequest={onProfileRequest}
|
||||
/>
|
||||
{sites
|
||||
.filter((s) => s.visible)
|
||||
|
||||
@@ -5,6 +5,7 @@ import L from 'leaflet';
|
||||
interface MeasurementToolProps {
|
||||
enabled: boolean;
|
||||
onComplete?: (distanceKm: number) => void;
|
||||
onProfileRequest?: (start: [number, number], end: [number, number]) => void;
|
||||
}
|
||||
|
||||
function haversineKm(
|
||||
@@ -39,7 +40,7 @@ const dotIcon = L.divIcon({
|
||||
html: '<div style="width:10px;height:10px;background:white;border:2px solid #333;border-radius:50%;"></div>',
|
||||
});
|
||||
|
||||
export default function MeasurementTool({ enabled, onComplete }: MeasurementToolProps) {
|
||||
export default function MeasurementTool({ enabled, onComplete, onProfileRequest }: MeasurementToolProps) {
|
||||
const map = useMap();
|
||||
const [points, setPoints] = useState<[number, number][]>([]);
|
||||
const pointsRef = useRef(points);
|
||||
@@ -116,6 +117,27 @@ export default function MeasurementTool({ enabled, onComplete }: MeasurementTool
|
||||
}}
|
||||
>
|
||||
Distance: {dist.toFixed(2)} km ({(dist * 1000).toFixed(0)} m)
|
||||
{points.length >= 2 && onProfileRequest && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onProfileRequest(points[0], points[points.length - 1]);
|
||||
}}
|
||||
style={{
|
||||
marginLeft: 10,
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
color: 'white',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
fontSize: 11,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
Terrain Profile
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
272
frontend/src/components/map/TerrainProfile.tsx
Normal file
272
frontend/src/components/map/TerrainProfile.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Canvas-based terrain elevation profile viewer.
|
||||
*
|
||||
* Shows elevation cross-section between two geographic points with:
|
||||
* - Green filled terrain area
|
||||
* - Dashed red LOS line from start to end
|
||||
* - Hover tooltip with elevation/distance at cursor
|
||||
* - Stats bar: total distance, min/max elevation
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { api } from '@/services/api.ts';
|
||||
import type { TerrainProfilePoint } from '@/services/api.ts';
|
||||
|
||||
interface TerrainProfileProps {
|
||||
start: [number, number]; // [lat, lon]
|
||||
end: [number, number]; // [lat, lon]
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CANVAS_W = 600;
|
||||
const CANVAS_H = 200;
|
||||
const PAD = { top: 20, right: 20, bottom: 30, left: 50 };
|
||||
const PLOT_W = CANVAS_W - PAD.left - PAD.right;
|
||||
const PLOT_H = CANVAS_H - PAD.top - PAD.bottom;
|
||||
|
||||
export default function TerrainProfile({ start, end, onClose }: TerrainProfileProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [profile, setProfile] = useState<TerrainProfilePoint[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hover, setHover] = useState<{ x: number; idx: number } | null>(null);
|
||||
|
||||
// Fetch profile data
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.getTerrainProfile(start[0], start[1], end[0], end[1], 200)
|
||||
.then((data) => {
|
||||
setProfile(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [start, end]);
|
||||
|
||||
// Draw chart
|
||||
const draw = useCallback(
|
||||
(hoverIdx: number | null) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !profile || profile.length === 0) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = CANVAS_W * dpr;
|
||||
canvas.height = CANVAS_H * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// Clear
|
||||
ctx.clearRect(0, 0, CANVAS_W, CANVAS_H);
|
||||
|
||||
const elevations = profile.map((p) => p.elevation);
|
||||
const distances = profile.map((p) => p.distance);
|
||||
const minElev = Math.min(...elevations);
|
||||
const maxElev = Math.max(...elevations);
|
||||
const maxDist = distances[distances.length - 1] || 1;
|
||||
|
||||
// Add 10% padding to elevation range
|
||||
const elevRange = maxElev - minElev || 1;
|
||||
const eMin = minElev - elevRange * 0.1;
|
||||
const eMax = maxElev + elevRange * 0.1;
|
||||
|
||||
const xScale = (d: number) => PAD.left + (d / maxDist) * PLOT_W;
|
||||
const yScale = (e: number) => PAD.top + PLOT_H - ((e - eMin) / (eMax - eMin)) * PLOT_H;
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = '#e5e7eb';
|
||||
ctx.lineWidth = 0.5;
|
||||
const nGridY = 5;
|
||||
for (let i = 0; i <= nGridY; i++) {
|
||||
const y = PAD.top + (i / nGridY) * PLOT_H;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(PAD.left, y);
|
||||
ctx.lineTo(PAD.left + PLOT_W, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Terrain fill
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(xScale(distances[0]), yScale(elevations[0]));
|
||||
for (let i = 1; i < profile.length; i++) {
|
||||
ctx.lineTo(xScale(distances[i]), yScale(elevations[i]));
|
||||
}
|
||||
ctx.lineTo(xScale(distances[distances.length - 1]), PAD.top + PLOT_H);
|
||||
ctx.lineTo(xScale(distances[0]), PAD.top + PLOT_H);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = 'rgba(34, 197, 94, 0.3)';
|
||||
ctx.fill();
|
||||
|
||||
// Terrain line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(xScale(distances[0]), yScale(elevations[0]));
|
||||
for (let i = 1; i < profile.length; i++) {
|
||||
ctx.lineTo(xScale(distances[i]), yScale(elevations[i]));
|
||||
}
|
||||
ctx.strokeStyle = '#16a34a';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// LOS dashed line (start elevation to end elevation)
|
||||
ctx.beginPath();
|
||||
ctx.setLineDash([6, 4]);
|
||||
ctx.moveTo(xScale(distances[0]), yScale(elevations[0]));
|
||||
ctx.lineTo(xScale(distances[distances.length - 1]), yScale(elevations[elevations.length - 1]));
|
||||
ctx.strokeStyle = '#ef4444';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Y axis labels
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (let i = 0; i <= nGridY; i++) {
|
||||
const elev = eMax - (i / nGridY) * (eMax - eMin);
|
||||
const y = PAD.top + (i / nGridY) * PLOT_H;
|
||||
ctx.fillText(`${Math.round(elev)}m`, PAD.left - 4, y);
|
||||
}
|
||||
|
||||
// X axis labels
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
const nGridX = 5;
|
||||
for (let i = 0; i <= nGridX; i++) {
|
||||
const d = (i / nGridX) * maxDist;
|
||||
const x = xScale(d);
|
||||
ctx.fillText(`${(d / 1000).toFixed(1)}km`, x, PAD.top + PLOT_H + 4);
|
||||
}
|
||||
|
||||
// Hover crosshair + tooltip
|
||||
if (hoverIdx !== null && hoverIdx >= 0 && hoverIdx < profile.length) {
|
||||
const p = profile[hoverIdx];
|
||||
const hx = xScale(p.distance);
|
||||
const hy = yScale(p.elevation);
|
||||
|
||||
// Vertical line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(hx, PAD.top);
|
||||
ctx.lineTo(hx, PAD.top + PLOT_H);
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
// Dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(hx, hy, 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#2563eb';
|
||||
ctx.fill();
|
||||
|
||||
// Tooltip
|
||||
const text = `${Math.round(p.elevation)}m @ ${(p.distance / 1000).toFixed(2)}km`;
|
||||
ctx.font = 'bold 11px monospace';
|
||||
const tw = ctx.measureText(text).width + 10;
|
||||
const tx = Math.min(hx + 8, CANVAS_W - tw - 4);
|
||||
const ty = Math.max(hy - 22, PAD.top);
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(tx, ty, tw, 18, 3);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text, tx + 5, ty + 9);
|
||||
}
|
||||
},
|
||||
[profile]
|
||||
);
|
||||
|
||||
// Re-draw on profile load or hover change
|
||||
useEffect(() => {
|
||||
draw(hover?.idx ?? null);
|
||||
}, [draw, hover]);
|
||||
|
||||
// Mouse move handler
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!profile || profile.length === 0) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const relX = (mx - PAD.left) / PLOT_W;
|
||||
if (relX < 0 || relX > 1) {
|
||||
setHover(null);
|
||||
return;
|
||||
}
|
||||
const idx = Math.round(relX * (profile.length - 1));
|
||||
setHover({ x: mx, idx });
|
||||
},
|
||||
[profile]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(() => setHover(null), []);
|
||||
|
||||
// Stats
|
||||
const minElev = profile ? Math.min(...profile.map((p) => p.elevation)) : 0;
|
||||
const maxElev = profile ? Math.max(...profile.map((p) => p.elevation)) : 0;
|
||||
const totalDist = profile && profile.length > 0 ? profile[profile.length - 1].distance : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-6 left-1/2 -translate-x-1/2 z-[1500]
|
||||
bg-white dark:bg-dark-surface rounded-lg shadow-xl border border-gray-200 dark:border-dark-border
|
||||
overflow-hidden"
|
||||
style={{ width: CANVAS_W + 16 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-100 dark:border-dark-border">
|
||||
<span className="text-xs font-semibold text-gray-700 dark:text-dark-text">
|
||||
Terrain Profile
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-white text-sm w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 dark:hover:bg-dark-border"
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<div className="px-2 py-1">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-[200px] text-sm text-gray-400">
|
||||
Loading profile...
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex items-center justify-center h-[200px] text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && profile && (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: CANVAS_W, height: CANVAS_H, cursor: 'crosshair' }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
{profile && profile.length > 0 && (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-gray-50 dark:bg-dark-bg text-[10px] text-gray-500 dark:text-dark-muted border-t border-gray-100 dark:border-dark-border">
|
||||
<span>Distance: {(totalDist / 1000).toFixed(2)} km</span>
|
||||
<span>Min: {Math.round(minElev)} m</span>
|
||||
<span>Max: {Math.round(maxElev)} m</span>
|
||||
<span>
|
||||
LOS: {profile[0].elevation <= profile[profile.length - 1].elevation ? 'Uphill' : 'Downhill'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -214,8 +214,8 @@ export default function SiteConfigModal({
|
||||
if (form.gain < 0 || form.gain > 30) {
|
||||
newErrors.gain = 'Gain must be 0-30 dBi';
|
||||
}
|
||||
if (form.frequency < 100 || form.frequency > 6000) {
|
||||
newErrors.frequency = 'Frequency must be 100-6000 MHz';
|
||||
if (form.frequency < 30 || form.frequency > 6000) {
|
||||
newErrors.frequency = 'Frequency must be 30-6000 MHz';
|
||||
}
|
||||
if (form.height < 1 || form.height > 100) {
|
||||
newErrors.height = 'Height must be 1-100m';
|
||||
|
||||
105
frontend/src/components/ui/GPUIndicator.tsx
Normal file
105
frontend/src/components/ui/GPUIndicator.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Small header badge showing the active compute backend (CPU or GPU).
|
||||
* Fetches status on mount. Clicking opens a dropdown to switch devices.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { api } from '@/services/api.ts';
|
||||
import type { GPUStatus, GPUDevice } from '@/services/api.ts';
|
||||
|
||||
export default function GPUIndicator() {
|
||||
const [status, setStatus] = useState<GPUStatus | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getGPUStatus().then(setStatus).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
if (!status) return null;
|
||||
|
||||
const isGPU = status.active_backend !== 'cpu';
|
||||
// Short label for header badge
|
||||
const label = isGPU
|
||||
? (status.active_device?.name?.split(' ')[0] ?? 'GPU')
|
||||
: 'CPU';
|
||||
|
||||
const handleSwitch = async (device: GPUDevice) => {
|
||||
setSwitching(true);
|
||||
try {
|
||||
await api.setGPUDevice(device.backend, device.index);
|
||||
const updated = await api.getGPUStatus();
|
||||
setStatus(updated);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setSwitching(false);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`px-2 py-1 rounded text-[11px] font-medium transition-colors
|
||||
${isGPU
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-300 dark:hover:bg-green-900/50'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-border dark:text-dark-muted dark:hover:bg-dark-muted'
|
||||
}`}
|
||||
title={`Compute: ${label}`}
|
||||
>
|
||||
{isGPU ? '\u26A1' : '\u2699\uFE0F'} {label}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full right-0 mt-1 w-56 bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-lg z-50 py-1">
|
||||
<div className="px-3 py-1.5 text-[10px] font-semibold text-gray-400 dark:text-dark-muted uppercase">
|
||||
Compute Devices
|
||||
</div>
|
||||
{status.available_devices.map((d) => {
|
||||
const isActive =
|
||||
status.active_device?.backend === d.backend &&
|
||||
status.active_device?.index === d.index;
|
||||
return (
|
||||
<button
|
||||
key={`${d.backend}-${d.index}`}
|
||||
onClick={() => !isActive && handleSwitch(d)}
|
||||
disabled={isActive || switching}
|
||||
className={`w-full text-left px-3 py-2 text-xs transition-colors
|
||||
${isActive
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
|
||||
: 'text-gray-700 hover:bg-gray-50 dark:text-dark-text dark:hover:bg-dark-border'
|
||||
}
|
||||
disabled:opacity-60`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{d.name}</span>
|
||||
{isActive && (
|
||||
<span className="text-[10px] text-blue-500 dark:text-blue-400">Active</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400 dark:text-dark-muted mt-0.5">
|
||||
{d.backend.toUpperCase()}
|
||||
{d.memory_mb > 0 && ` \u2022 ${d.memory_mb} MB`}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,39 @@
|
||||
import type { FrequencyBand } from '@/types/index.ts';
|
||||
|
||||
export const COMMON_FREQUENCIES: FrequencyBand[] = [
|
||||
{
|
||||
value: 70,
|
||||
name: 'VHF Low',
|
||||
range: '30-88 MHz',
|
||||
type: 'VHF',
|
||||
characteristics: {
|
||||
range: 'long',
|
||||
penetration: 'excellent',
|
||||
typical: 'Military tactical, long-range ground wave',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 225,
|
||||
name: 'Military UHF',
|
||||
range: '225-400 MHz',
|
||||
type: 'UHF',
|
||||
characteristics: {
|
||||
range: 'long',
|
||||
penetration: 'good',
|
||||
typical: 'NATO MILCOM, SINCGARS, air-ground',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 700,
|
||||
name: 'Band 28',
|
||||
range: '703-803 MHz',
|
||||
type: 'LTE',
|
||||
characteristics: {
|
||||
range: 'long',
|
||||
penetration: 'excellent',
|
||||
typical: 'Extended range LTE, first responder (FirstNet)',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 800,
|
||||
name: 'Band 20',
|
||||
@@ -12,6 +45,17 @@ export const COMMON_FREQUENCIES: FrequencyBand[] = [
|
||||
typical: 'Rural coverage, deep building penetration',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 900,
|
||||
name: 'Band 8',
|
||||
range: '880-960 MHz',
|
||||
type: 'LTE',
|
||||
characteristics: {
|
||||
range: 'long',
|
||||
penetration: 'excellent',
|
||||
typical: 'GSM refarming, IoT, rural coverage',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 1800,
|
||||
name: 'Band 3',
|
||||
@@ -91,16 +135,16 @@ export const COMMON_FREQUENCIES: FrequencyBand[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const QUICK_FREQUENCIES = [800, 1800, 1900, 2100, 2600];
|
||||
export const QUICK_FREQUENCIES = [700, 800, 900, 1800, 1900, 2100, 2600];
|
||||
|
||||
// Tactical radio presets for UHF/VHF
|
||||
export const TACTICAL_FREQUENCIES = [150, 450];
|
||||
export const TACTICAL_FREQUENCIES = [70, 150, 225, 450];
|
||||
|
||||
// All quick frequencies grouped by band type
|
||||
export const FREQUENCY_GROUPS = {
|
||||
LTE: [800, 1800, 1900, 2100, 2600],
|
||||
UHF: [450],
|
||||
VHF: [150],
|
||||
VHF: [70, 150],
|
||||
UHF: [225, 450],
|
||||
LTE: [700, 800, 900, 1800, 1900, 2100, 2600],
|
||||
'5G': [3500],
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -212,6 +212,53 @@ class ApiService {
|
||||
if (!response.ok) throw new Error('Failed to get cache stats');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// === GPU API ===
|
||||
|
||||
async getGPUStatus(): Promise<GPUStatus> {
|
||||
const response = await fetch(`${API_BASE}/api/gpu/status`);
|
||||
if (!response.ok) throw new Error('Failed to get GPU status');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getGPUDevices(): Promise<{ devices: GPUDevice[] }> {
|
||||
const response = await fetch(`${API_BASE}/api/gpu/devices`);
|
||||
if (!response.ok) throw new Error('Failed to get GPU devices');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async setGPUDevice(backend: string, index: number = 0): Promise<{ status: string; backend: string; device: string }> {
|
||||
const response = await fetch(`${API_BASE}/api/gpu/set`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ backend, index }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ detail: 'Failed to set GPU device' }));
|
||||
throw new Error(err.detail || 'Failed to set GPU device');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// === Terrain Profile API ===
|
||||
|
||||
async getTerrainProfile(
|
||||
lat1: number, lon1: number,
|
||||
lat2: number, lon2: number,
|
||||
points: number = 100,
|
||||
): Promise<TerrainProfilePoint[]> {
|
||||
const params = new URLSearchParams({
|
||||
lat1: lat1.toString(),
|
||||
lon1: lon1.toString(),
|
||||
lat2: lat2.toString(),
|
||||
lon2: lon2.toString(),
|
||||
points: points.toString(),
|
||||
});
|
||||
const response = await fetch(`${API_BASE}/api/terrain/profile?${params}`);
|
||||
if (!response.ok) throw new Error('Failed to get terrain profile');
|
||||
const data = await response.json();
|
||||
return data.profile ?? data;
|
||||
}
|
||||
}
|
||||
|
||||
// === Region types ===
|
||||
@@ -244,4 +291,29 @@ export interface CacheStats {
|
||||
vegetation_mb: number;
|
||||
}
|
||||
|
||||
// === GPU types ===
|
||||
|
||||
export interface GPUDevice {
|
||||
backend: string;
|
||||
index: number;
|
||||
name: string;
|
||||
memory_mb: number;
|
||||
}
|
||||
|
||||
export interface GPUStatus {
|
||||
active_backend: string;
|
||||
active_device: GPUDevice | null;
|
||||
gpu_available: boolean;
|
||||
available_devices: GPUDevice[];
|
||||
}
|
||||
|
||||
// === Terrain Profile types ===
|
||||
|
||||
export interface TerrainProfilePoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
elevation: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
export const api = new ApiService();
|
||||
|
||||
@@ -73,6 +73,7 @@ function buildApiSettings(settings: CoverageSettings) {
|
||||
use_atmospheric: settings.use_atmospheric,
|
||||
temperature_c: settings.temperature_c,
|
||||
humidity_percent: settings.humidity_percent,
|
||||
fading_margin: settings.fading_margin,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -166,6 +167,8 @@ export const useCoverageStore = create<CoverageState>((set, get) => ({
|
||||
use_atmospheric: false,
|
||||
temperature_c: 15,
|
||||
humidity_percent: 50,
|
||||
// Fading margin
|
||||
fading_margin: 0,
|
||||
},
|
||||
heatmapVisible: true,
|
||||
error: null,
|
||||
|
||||
@@ -10,9 +10,11 @@ interface SettingsState {
|
||||
showGrid: boolean;
|
||||
measurementMode: boolean;
|
||||
showElevationInfo: boolean;
|
||||
showBoundary: boolean;
|
||||
showElevationOverlay: boolean;
|
||||
elevationOpacity: number;
|
||||
setTheme: (theme: Theme) => void;
|
||||
setShowBoundary: (show: boolean) => void;
|
||||
setShowTerrain: (show: boolean) => void;
|
||||
setTerrainOpacity: (opacity: number) => void;
|
||||
setShowGrid: (show: boolean) => void;
|
||||
@@ -42,6 +44,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
showGrid: false,
|
||||
measurementMode: false,
|
||||
showElevationInfo: false,
|
||||
showBoundary: false,
|
||||
showElevationOverlay: false,
|
||||
elevationOpacity: 0.5,
|
||||
setTheme: (theme: Theme) => {
|
||||
@@ -53,6 +56,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
setShowGrid: (show: boolean) => set({ showGrid: show }),
|
||||
setMeasurementMode: (mode: boolean) => set({ measurementMode: mode }),
|
||||
setShowElevationInfo: (show: boolean) => set({ showElevationInfo: show }),
|
||||
setShowBoundary: (show: boolean) => set({ showBoundary: show }),
|
||||
setShowElevationOverlay: (show: boolean) => set({ showElevationOverlay: show }),
|
||||
setElevationOpacity: (opacity: number) => set({ elevationOpacity: opacity }),
|
||||
}),
|
||||
|
||||
@@ -64,6 +64,8 @@ export interface CoverageSettings {
|
||||
use_atmospheric?: boolean;
|
||||
temperature_c?: number;
|
||||
humidity_percent?: number;
|
||||
// Fading margin
|
||||
fading_margin?: number; // dB additional safety loss
|
||||
}
|
||||
|
||||
export interface GridPoint {
|
||||
|
||||
Reference in New Issue
Block a user