@mytec: iter3.5.0 ready for testing
This commit is contained in:
@@ -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