@mytec: iter3.5.0 ready for testing

This commit is contained in:
2026-02-03 10:32:38 +02:00
parent f46bf16428
commit 3b36535d4e
17 changed files with 860 additions and 68 deletions

View File

@@ -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>

View File

@@ -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 */}

View File

@@ -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)

View File

@@ -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>
)}
</>

View 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>
);
}

View File

@@ -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';

View 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>
);
}

View File

@@ -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;

View File

@@ -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();

View File

@@ -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,

View File

@@ -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 }),
}),

View File

@@ -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 {