@mytec: WebGL works

This commit is contained in:
2026-02-06 22:17:24 +02:00
parent 81e078e92a
commit acfd9b8f7b
31 changed files with 4427 additions and 156 deletions

View File

@@ -1194,19 +1194,6 @@
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz",
"integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz",
@@ -3449,6 +3436,20 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz",
"integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",

View File

@@ -6,6 +6,7 @@ import { useSitesStore } from '@/store/sites.ts';
import { useCoverageStore } from '@/store/coverage.ts';
import { useSettingsStore } from '@/store/settings.ts';
import { useHistoryStore, pushToFuture, pushToPast } from '@/store/history.ts';
import { useToolStore } from '@/store/tools.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts.ts';
import { useUnsavedChanges } from '@/hooks/useUnsavedChanges.ts';
@@ -13,6 +14,7 @@ import { logger } from '@/utils/logger.ts';
import { db } from '@/db/schema.ts';
import MapView from '@/components/map/Map.tsx';
import GeographicHeatmap from '@/components/map/GeographicHeatmap.tsx';
import WebGLCoverageLayer from '@/components/map/WebGLCoverageLayer.tsx';
import CoverageBoundary from '@/components/map/CoverageBoundary.tsx';
import HeatmapLegend from '@/components/map/HeatmapLegend.tsx';
import SiteList from '@/components/panels/SiteList.tsx';
@@ -29,6 +31,8 @@ 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 LinkBudgetPanel from '@/components/panels/LinkBudgetPanel.tsx';
import LinkBudgetOverlay from '@/components/map/LinkBudgetOverlay.tsx';
import Button from '@/components/ui/Button.tsx';
import NumberInput from '@/components/ui/NumberInput.tsx';
import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx';
@@ -60,7 +64,7 @@ async function restoreSites(snapshot: Site[]) {
export default function App() {
const loadSites = useSitesStore((s) => s.loadSites);
const sites = useSitesStore((s) => s.sites);
const setPlacingMode = useSitesStore((s) => s.setPlacingMode);
const selectedSiteId = useSitesStore((s) => s.selectedSiteId);
const coverageResult = useCoverageStore((s) => s.result);
const isCalculating = useCoverageStore((s) => s.isCalculating);
@@ -110,15 +114,20 @@ export default function App() {
const setTerrainOpacity = useSettingsStore((s) => s.setTerrainOpacity);
const showGrid = useSettingsStore((s) => s.showGrid);
const setShowGrid = useSettingsStore((s) => s.setShowGrid);
const measurementMode = useSettingsStore((s) => s.measurementMode);
const setMeasurementMode = useSettingsStore((s) => s.setMeasurementMode);
const showElevationInfo = useSettingsStore((s) => s.showElevationInfo);
// Tool store (centralized active tool state)
const activeTool = useToolStore((s) => s.activeTool);
const setActiveTool = useToolStore((s) => s.setActiveTool);
const clearTool = useToolStore((s) => s.clearTool);
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);
const setElevationOpacity = useSettingsStore((s) => s.setElevationOpacity);
const useWebGLCoverage = useSettingsStore((s) => s.useWebGLCoverage);
const setUseWebGLCoverage = useSettingsStore((s) => s.setUseWebGLCoverage);
// History (undo/redo)
const canUndo = useHistoryStore((s) => s.canUndo);
@@ -137,6 +146,8 @@ export default function App() {
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);
const [showLinkBudget, setShowLinkBudget] = useState(false);
const [linkBudgetRxPoint, setLinkBudgetRxPoint] = useState<{ lat: number; lon: number } | null>(null);
// Region wizard for first-run (desktop mode only)
const [showWizard, setShowWizard] = useState(false);
@@ -213,17 +224,26 @@ export default function App() {
loadSites();
}, [loadSites]);
// Handle map click -> open modal with coordinates
const handleMapClick = useCallback(
// Handle site placement from map click
const handleSitePlacement = useCallback(
(lat: number, lon: number) => {
setModalState({
isOpen: true,
mode: 'create',
initialData: { lat, lon },
});
setPlacingMode(false);
// Tool store clearTool() is called by MapClickHandler after placement
},
[setPlacingMode]
[]
);
// Handle RX point placement for Link Budget
const handleRxPlacement = useCallback(
(lat: number, lon: number) => {
setLinkBudgetRxPoint({ lat, lon });
// Tool store clearTool() is called by MapClickHandler after placement
},
[]
);
const handleEditSite = useCallback((site: Site) => {
@@ -668,20 +688,38 @@ export default function App() {
{/* Map */}
<div className="flex-1 relative">
<MapView
onMapClick={handleMapClick}
onSitePlacement={handleSitePlacement}
onRxPlacement={handleRxPlacement}
onEditSite={handleEditSite}
onProfileRequest={(start, end) => setProfileEndpoints({ start, end })}
showLinkBudget={showLinkBudget}
onToggleLinkBudget={() => setShowLinkBudget(!showLinkBudget)}
>
{/* Show partial results during tiled calculation, or final result */}
{(coverageResult || (isCalculating && partialPoints.length > 0)) && (
<>
<GeographicHeatmap
points={isCalculating && partialPoints.length > 0 ? partialPoints : (coverageResult?.points ?? [])}
visible={heatmapVisible}
opacity={settings.heatmapOpacity}
radiusMeters={settings.heatmapRadius}
rsrpThreshold={settings.rsrpThreshold}
/>
{/* Only render ONE layer - WebGL or Canvas, never both */}
{useWebGLCoverage && (
<WebGLCoverageLayer
key="webgl-coverage"
points={isCalculating && partialPoints.length > 0 ? partialPoints : (coverageResult?.points ?? [])}
visible={heatmapVisible}
opacity={settings.heatmapOpacity}
minRsrp={-130}
maxRsrp={-50}
onWebGLFailed={() => setUseWebGLCoverage(false)}
/>
)}
{!useWebGLCoverage && (
<GeographicHeatmap
key="canvas-coverage"
points={isCalculating && partialPoints.length > 0 ? partialPoints : (coverageResult?.points ?? [])}
visible={heatmapVisible}
opacity={settings.heatmapOpacity}
radiusMeters={settings.heatmapRadius}
rsrpThreshold={settings.rsrpThreshold}
/>
)}
{coverageResult && (
<CoverageBoundary
points={coverageResult.points.filter(p => p.rsrp >= settings.rsrpThreshold)}
@@ -692,7 +730,29 @@ export default function App() {
)}
</>
)}
{/* Link Budget TX-RX overlay */}
{showLinkBudget && linkBudgetRxPoint && (() => {
const txSite = sites.find(s => s.id === selectedSiteId);
return (
<LinkBudgetOverlay
txPoint={txSite ? { lat: txSite.lat, lon: txSite.lon } : null}
rxPoint={linkBudgetRxPoint}
onRxDrag={(lat, lon) => setLinkBudgetRxPoint({ lat, lon })}
/>
);
})()}
</MapView>
{activeTool === 'rx-placement' && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[2000] bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg text-sm font-medium flex items-center gap-2">
<span>Click on map to set RX point</span>
<button
onClick={() => clearTool()}
className="text-white/70 hover:text-white ml-2"
>
Cancel
</button>
</div>
)}
<HeatmapLegend />
<ResultsPanel />
{profileEndpoints && (
@@ -702,6 +762,19 @@ export default function App() {
onClose={() => setProfileEndpoints(null)}
/>
)}
{showLinkBudget && (
<div className="absolute top-20 left-4 z-[1500]">
<LinkBudgetPanel
rxPoint={linkBudgetRxPoint}
onRequestMapClick={() => setActiveTool('rx-placement')}
onClose={() => {
setShowLinkBudget(false);
clearTool();
setLinkBudgetRxPoint(null);
}}
/>
</div>
)}
</div>
{/* Side panel */}
@@ -793,6 +866,29 @@ export default function App() {
unit="%"
hint="Transparency of the RF coverage overlay"
/>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-dark-text">
Smooth Rendering
</label>
<p className="text-xs text-gray-400 dark:text-dark-muted">
WebGL interpolation for smooth gradients
</p>
</div>
<button
onClick={() => setUseWebGLCoverage(!useWebGLCoverage)}
className={`relative w-11 h-6 rounded-full transition-colors ${
useWebGLCoverage ? 'bg-blue-500' : 'bg-gray-300 dark:bg-dark-border'
}`}
>
<span
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${
useWebGLCoverage ? 'translate-x-5' : ''
}`}
/>
</button>
</div>
{!useWebGLCoverage && (
<div>
<label className="text-sm font-medium text-gray-700 dark:text-dark-text">
Heatmap Quality
@@ -822,6 +918,7 @@ export default function App() {
</p>
)}
</div>
)}
{/* Propagation Model Preset */}
<div>
<label className="text-sm font-medium text-gray-700 dark:text-dark-text">
@@ -1098,15 +1195,15 @@ export default function App() {
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-dark-text">
<input
type="checkbox"
checked={measurementMode}
onChange={(e) => setMeasurementMode(e.target.checked)}
checked={activeTool === 'ruler'}
onChange={(e) => e.target.checked ? setActiveTool('ruler') : clearTool()}
className="w-4 h-4 rounded border-gray-300 dark:border-dark-border accent-orange-600"
/>
Distance Measurement
</label>
{measurementMode && (
{activeTool === 'ruler' && (
<p className="text-xs text-gray-400 dark:text-dark-muted pl-6">
Click to add points. Right-click to finish.
Click start and end points. Esc to cancel.
</p>
)}
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-dark-text">
@@ -1140,7 +1237,7 @@ export default function App() {
/>
</div>
)}
</div>
</div>
</div>
{/* Data Cache Status */}

View File

@@ -45,6 +45,12 @@ export default function ElevationLayer({ visible, opacity }: ElevationLayerProps
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const lastBoundsRef = useRef<string>('');
const opacityRef = useRef(opacity);
// Keep opacity ref in sync
useEffect(() => {
opacityRef.current = opacity;
}, [opacity]);
const removeOverlay = useCallback(() => {
if (overlayRef.current) {
@@ -119,21 +125,23 @@ export default function ElevationLayer({ visible, opacity }: ElevationLayerProps
// Remove old overlay
removeOverlay();
// Add new overlay
// Add new overlay (opacity will be set by the dedicated effect)
const leafletBounds = L.latLngBounds(
[data.bbox.min_lat, data.bbox.min_lon],
[data.bbox.max_lat, data.bbox.max_lon],
);
overlayRef.current = L.imageOverlay(canvas.toDataURL(), leafletBounds, {
opacity,
opacity: 0.5, // Default, will be updated by opacity effect
interactive: false,
zIndex: 97,
});
overlayRef.current.addTo(map);
// Apply current opacity immediately using ref
overlayRef.current.setOpacity(opacityRef.current);
} catch (_e) {
// Silently ignore fetch errors (network issues, aborts, etc.)
}
}, [map, opacity, removeOverlay]);
}, [map, removeOverlay]);
// Update opacity on existing overlay
useEffect(() => {

View File

@@ -0,0 +1,83 @@
/**
* Link Budget Overlay
*
* Shows RX marker and dashed line from TX site to RX point.
*/
import { useEffect, useState } from 'react';
import { Marker, Polyline } from 'react-leaflet';
import L from 'leaflet';
interface LinkBudgetOverlayProps {
txPoint: { lat: number; lon: number } | null;
rxPoint: { lat: number; lon: number } | null;
onRxDrag?: (lat: number, lon: number) => void;
}
// Orange circle icon for RX marker
const rxIcon = L.divIcon({
className: 'rx-marker',
html: '<div style="width: 14px; height: 14px; background: #f97316; border: 2px solid white; border-radius: 50%; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>',
iconSize: [14, 14],
iconAnchor: [7, 7],
});
export default function LinkBudgetOverlay({ txPoint, rxPoint, onRxDrag }: LinkBudgetOverlayProps) {
const [markerRef, setMarkerRef] = useState<L.Marker | null>(null);
// Handle drag events
useEffect(() => {
if (!markerRef || !onRxDrag) return;
const handleDrag = () => {
const pos = markerRef.getLatLng();
onRxDrag(pos.lat, pos.lng);
};
markerRef.on('drag', handleDrag);
markerRef.on('dragend', handleDrag);
return () => {
markerRef.off('drag', handleDrag);
markerRef.off('dragend', handleDrag);
};
}, [markerRef, onRxDrag]);
if (!rxPoint) return null;
const rxLatLng: [number, number] = [rxPoint.lat, rxPoint.lon];
const txLatLng: [number, number] | null = txPoint ? [txPoint.lat, txPoint.lon] : null;
return (
<>
{/* Dashed line from TX to RX */}
{txLatLng && (
<Polyline
positions={[txLatLng, rxLatLng]}
pathOptions={{
color: '#f97316',
weight: 2,
dashArray: '8, 4',
opacity: 0.8,
}}
/>
)}
{/* RX marker (draggable) */}
<Marker
position={rxLatLng}
icon={rxIcon}
draggable={!!onRxDrag}
ref={(ref) => setMarkerRef(ref)}
eventHandlers={{
dragend: (e) => {
if (onRxDrag) {
const pos = e.target.getLatLng();
onRxDrag(pos.lat, pos.lng);
}
},
}}
/>
</>
);
}

View File

@@ -1,10 +1,12 @@
import { useRef, useCallback, useEffect } from 'react';
import { useRef, useCallback, useEffect, useState } from 'react';
import { MapContainer, TileLayer, useMapEvents, useMap } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import type { Map as LeafletMap } from 'leaflet';
import L from 'leaflet';
import type { Site } from '@/types/index.ts';
import { useSitesStore } from '@/store/sites.ts';
import { useSettingsStore } from '@/store/settings.ts';
import { useToolStore } from '@/store/tools.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
import SiteMarker from './SiteMarker.tsx';
import MapExtras from './MapExtras.tsx';
@@ -14,23 +16,72 @@ import ElevationDisplay from './ElevationDisplay.tsx';
import ElevationLayer from './ElevationLayer.tsx';
interface MapViewProps {
onMapClick: (lat: number, lon: number) => void;
onSitePlacement: (lat: number, lon: number) => void;
onRxPlacement?: (lat: number, lon: number) => void;
onEditSite: (site: Site) => void;
onProfileRequest?: (start: [number, number], end: [number, number]) => void;
showLinkBudget?: boolean;
onToggleLinkBudget?: () => void;
children?: React.ReactNode;
}
const SNAP_THRESHOLD_PX = 20;
/**
* Unified map click handler that dispatches based on active tool
*/
function MapClickHandler({
onMapClick,
onSitePlacement,
onRxPlacement,
onRulerClick,
sites,
}: {
onMapClick: (lat: number, lon: number) => void;
onSitePlacement: (lat: number, lon: number) => void;
onRxPlacement?: (lat: number, lon: number) => void;
onRulerClick: (lat: number, lon: number) => void;
sites: Site[];
}) {
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
const activeTool = useToolStore((s) => s.activeTool);
const clearTool = useToolStore((s) => s.clearTool);
const map = useMap();
useMapEvents({
click: (e) => {
if (isPlacingMode) {
onMapClick(e.latlng.lat, e.latlng.lng);
switch (activeTool) {
case 'ruler':
// Snap to nearest site if within threshold
const clickPoint = map.latLngToContainerPoint(e.latlng);
let snappedLat = e.latlng.lat;
let snappedLon = e.latlng.lng;
for (const site of sites) {
const sitePoint = map.latLngToContainerPoint(L.latLng(site.lat, site.lon));
const pixelDist = clickPoint.distanceTo(sitePoint);
if (pixelDist < SNAP_THRESHOLD_PX) {
snappedLat = site.lat;
snappedLon = site.lon;
break;
}
}
onRulerClick(snappedLat, snappedLon);
break;
case 'rx-placement':
if (onRxPlacement) {
onRxPlacement(e.latlng.lat, e.latlng.lng);
clearTool(); // Single click action
}
break;
case 'site-placement':
onSitePlacement(e.latlng.lat, e.latlng.lng);
clearTool(); // Single click action
break;
case 'none':
default:
// No action on map click — just pan/zoom
break;
}
},
});
@@ -38,6 +89,61 @@ function MapClickHandler({
return null;
}
/**
* Component to apply cursor classes based on active tool
*/
function CursorManager() {
const map = useMap();
const activeTool = useToolStore((s) => s.activeTool);
useEffect(() => {
const container = map.getContainer();
// Remove all tool cursors
container.classList.remove('tool-ruler', 'tool-rx-placement', 'tool-site-placement');
switch (activeTool) {
case 'ruler':
container.classList.add('tool-ruler');
break;
case 'rx-placement':
container.classList.add('tool-rx-placement');
break;
case 'site-placement':
container.classList.add('tool-site-placement');
break;
default:
// Default cursor (arrow)
break;
}
}, [map, activeTool]);
return null;
}
/**
* Right-click handler for ruler mode
*/
function RulerRightClickHandler({ onRightClick }: { onRightClick: () => void }) {
const activeTool = useToolStore((s) => s.activeTool);
const map = useMap();
useEffect(() => {
if (activeTool !== 'ruler') return;
const handleContextMenu = (e: L.LeafletMouseEvent) => {
L.DomEvent.preventDefault(e.originalEvent);
onRightClick();
};
map.on('contextmenu', handleContextMenu);
return () => {
map.off('contextmenu', handleContextMenu);
};
}, [map, activeTool, onRightClick]);
return null;
}
/**
* Inner component that exposes the map instance via ref callback
*/
@@ -49,23 +155,72 @@ function MapRefSetter({ mapRef }: { mapRef: React.MutableRefObject<LeafletMap |
return null;
}
export default function MapView({ onMapClick, onEditSite, onProfileRequest, children }: MapViewProps) {
export default function MapView({ onSitePlacement, onRxPlacement, onEditSite, onProfileRequest, showLinkBudget, onToggleLinkBudget, children }: MapViewProps) {
const sites = useSitesStore((s) => s.sites);
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
const showTerrain = useSettingsStore((s) => s.showTerrain);
const terrainOpacity = useSettingsStore((s) => s.terrainOpacity);
const setShowTerrain = useSettingsStore((s) => s.setShowTerrain);
const showGrid = useSettingsStore((s) => s.showGrid);
const setShowGrid = useSettingsStore((s) => s.setShowGrid);
const measurementMode = useSettingsStore((s) => s.measurementMode);
const setMeasurementMode = useSettingsStore((s) => s.setMeasurementMode);
const showElevationInfo = useSettingsStore((s) => s.showElevationInfo);
const showElevationOverlay = useSettingsStore((s) => s.showElevationOverlay);
const setShowElevationOverlay = useSettingsStore((s) => s.setShowElevationOverlay);
const elevationOpacity = useSettingsStore((s) => s.elevationOpacity);
const addToast = useToastStore((s) => s.addToast);
// Tool store
const activeTool = useToolStore((s) => s.activeTool);
const setActiveTool = useToolStore((s) => s.setActiveTool);
const clearTool = useToolStore((s) => s.clearTool);
const mapRef = useRef<LeafletMap | null>(null);
// Ruler points state (managed here since MeasurementTool is now controlled by tool store)
const [rulerPoints, setRulerPoints] = useState<[number, number][]>([]);
// Ruler limited to exactly 2 points (point-to-point measurement)
const handleRulerClick = useCallback((lat: number, lon: number) => {
setRulerPoints(prev => {
if (prev.length === 0) {
// First point
return [[lat, lon]];
} else if (prev.length === 1) {
// Second point — measurement complete
return [prev[0], [lat, lon]];
} else {
// Already 2 points — start new measurement
return [[lat, lon]];
}
});
}, []);
const handleRulerRightClick = useCallback(() => {
if (rulerPoints.length >= 2) {
// Calculate total distance
let total = 0;
for (let i = 1; i < rulerPoints.length; i++) {
const [lat1, lon1] = rulerPoints[i - 1];
const [lat2, lon2] = rulerPoints[i];
const R = 6371;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) ** 2;
total += R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
addToast(`Distance: ${total.toFixed(2)} km (${(total * 1000).toFixed(0)} m)`, 'info');
}
setRulerPoints([]);
clearTool();
}, [rulerPoints, addToast, clearTool]);
// Clear ruler points when tool changes away from ruler
useEffect(() => {
if (activeTool !== 'ruler') {
setRulerPoints([]);
}
}, [activeTool]);
const handleFitToSites = useCallback(() => {
if (sites.length === 0 || !mapRef.current) return;
const bounds = sites.map((site) => [site.lat, site.lon] as [number, number]);
@@ -76,14 +231,24 @@ export default function MapView({ onMapClick, onEditSite, onProfileRequest, chil
mapRef.current?.setView([48.4, 35.0], 7);
}, []);
// Toggle ruler tool
const handleRulerToggle = useCallback(() => {
if (activeTool === 'ruler') {
clearTool();
} else {
setActiveTool('ruler');
}
}, [activeTool, setActiveTool, clearTool]);
return (
<>
<MapContainer
center={[48.4, 35.0]}
zoom={7}
className={`w-full h-full ${isPlacingMode ? 'cursor-crosshair' : ''}`}
className="w-full h-full"
>
<MapRefSetter mapRef={mapRef} />
<CursorManager />
{/* Base OSM layer */}
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
@@ -100,16 +265,21 @@ export default function MapView({ onMapClick, onEditSite, onProfileRequest, chil
)}
{/* Elevation color overlay from SRTM terrain data */}
<ElevationLayer visible={showElevationOverlay} opacity={elevationOpacity} />
<MapClickHandler onMapClick={onMapClick} />
{/* Unified click handler */}
<MapClickHandler
onSitePlacement={onSitePlacement}
onRxPlacement={onRxPlacement}
onRulerClick={handleRulerClick}
sites={sites}
/>
{/* Right-click handler for ruler */}
<RulerRightClickHandler onRightClick={handleRulerRightClick} />
<MapExtras />
{showElevationInfo && <ElevationDisplay />}
<CoordinateGrid visible={showGrid} />
{/* Ruler visualization (only points and line, no click handling) */}
<MeasurementTool
enabled={measurementMode}
onComplete={(distKm) => {
addToast(`Distance: ${distKm.toFixed(2)} km (${(distKm * 1000).toFixed(0)} m)`, 'info');
setMeasurementMode(false);
}}
points={rulerPoints}
onProfileRequest={onProfileRequest}
/>
{sites
@@ -163,12 +333,12 @@ export default function MapView({ onMapClick, onEditSite, onProfileRequest, chil
Grid
</button>
<button
onClick={() => setMeasurementMode(!measurementMode)}
onClick={handleRulerToggle}
className={`bg-white dark:bg-dark-surface shadow-lg rounded px-3 py-2 text-sm
hover:bg-gray-50 dark:hover:bg-dark-border transition-colors
text-gray-700 dark:text-dark-text min-h-[36px]
${measurementMode ? 'ring-2 ring-orange-500' : ''}`}
title={measurementMode ? 'Exit measurement mode' : 'Measure distance (click points, right-click to finish)'}
${activeTool === 'ruler' ? 'ring-2 ring-orange-500' : ''}`}
title={activeTool === 'ruler' ? 'Exit measurement mode' : 'Measure point-to-point distance'}
>
Ruler
</button>
@@ -182,6 +352,18 @@ export default function MapView({ onMapClick, onEditSite, onProfileRequest, chil
>
Elev
</button>
{onToggleLinkBudget && (
<button
onClick={onToggleLinkBudget}
className={`bg-white dark:bg-dark-surface shadow-lg rounded px-3 py-2 text-sm
hover:bg-gray-50 dark:hover:bg-dark-border transition-colors
text-gray-700 dark:text-dark-text min-h-[36px]
${showLinkBudget ? 'ring-2 ring-purple-500' : ''}`}
title={showLinkBudget ? 'Close Link Budget Calculator' : 'Open Link Budget Calculator'}
>
LB
</button>
)}
</div>
</>
);

View File

@@ -1,10 +1,16 @@
import { useEffect, useRef, useState } from 'react';
import { useMap, Polyline, Marker } from 'react-leaflet';
/**
* Ruler/Measurement Tool Visualization
*
* Pure visualization component - receives points from parent,
* click handling is done by the centralized MapClickHandler.
*/
import { useEffect, useRef } from 'react';
import { Polyline, Marker } from 'react-leaflet';
import L from 'leaflet';
interface MeasurementToolProps {
enabled: boolean;
onComplete?: (distanceKm: number) => void;
points: [number, number][];
onProfileRequest?: (start: [number, number], end: [number, number]) => void;
}
@@ -40,50 +46,18 @@ 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, onProfileRequest }: MeasurementToolProps) {
const map = useMap();
const [points, setPoints] = useState<[number, number][]>([]);
const pointsRef = useRef(points);
useEffect(() => {
pointsRef.current = points;
}, [points]);
export default function MeasurementTool({ points, onProfileRequest }: MeasurementToolProps) {
const overlayRef = useRef<HTMLDivElement>(null);
// Clear on disable
/* eslint-disable react-hooks/set-state-in-effect */
// Use Leaflet's DOM event utility to block click propagation to the map
useEffect(() => {
if (!enabled) {
setPoints([]);
if (overlayRef.current) {
L.DomEvent.disableClickPropagation(overlayRef.current);
L.DomEvent.disableScrollPropagation(overlayRef.current);
}
}, [enabled]);
/* eslint-enable react-hooks/set-state-in-effect */
}, [points.length]); // Re-run when overlay appears/disappears
// Click handler: add measurement point
useEffect(() => {
if (!enabled) return;
const handleClick = (e: L.LeafletMouseEvent) => {
setPoints((prev) => [...prev, [e.latlng.lat, e.latlng.lng]]);
};
const handleRightClick = (e: L.LeafletMouseEvent) => {
L.DomEvent.preventDefault(e.originalEvent);
const pts = pointsRef.current;
if (pts.length >= 2 && onComplete) {
onComplete(totalDistance(pts));
}
setPoints([]);
};
map.on('click', handleClick);
map.on('contextmenu', handleRightClick);
return () => {
map.off('click', handleClick);
map.off('contextmenu', handleRightClick);
};
}, [map, enabled, onComplete]);
if (!enabled || points.length === 0) return null;
if (points.length === 0) return null;
const dist = totalDistance(points);
@@ -100,6 +74,7 @@ export default function MeasurementTool({ enabled, onComplete, onProfileRequest
))}
{dist > 0 && (
<div
ref={overlayRef}
style={{
position: 'absolute',
top: '10px',
@@ -110,7 +85,6 @@ export default function MeasurementTool({ enabled, onComplete, onProfileRequest
padding: '6px 14px',
borderRadius: '6px',
zIndex: 2000,
pointerEvents: 'none',
fontSize: '13px',
fontWeight: 600,
letterSpacing: '0.3px',
@@ -119,11 +93,7 @@ export default function MeasurementTool({ enabled, onComplete, onProfileRequest
Distance: {dist.toFixed(2)} km ({(dist * 1000).toFixed(0)} m)
{points.length >= 2 && onProfileRequest && (
<button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onProfileRequest(points[0], points[points.length - 1]);
}}
onClick={() => onProfileRequest(points[0], points[points.length - 1])}
style={{
marginLeft: 10,
background: 'rgba(255,255,255,0.15)',
@@ -133,7 +103,6 @@ export default function MeasurementTool({ enabled, onComplete, onProfileRequest
borderRadius: 4,
cursor: 'pointer',
fontSize: 11,
pointerEvents: 'auto',
}}
>
Terrain Profile

View File

@@ -1,51 +1,77 @@
/**
* Canvas-based terrain elevation profile viewer.
* Canvas-based terrain elevation profile viewer with Fresnel zone visualization.
*
* Shows elevation cross-section between two geographic points with:
* - Green filled terrain area
* - Dashed red LOS line from start to end
* - Optional Fresnel zone ellipse (light blue)
* - Red highlighting where terrain intrudes Fresnel zone
* - Hover tooltip with elevation/distance at cursor
* - Stats bar: total distance, min/max elevation
* - Stats bar: total distance, min/max elevation, Fresnel status
*/
import { useEffect, useRef, useState, useCallback } from 'react';
import L from 'leaflet';
import { api } from '@/services/api.ts';
import type { TerrainProfilePoint } from '@/services/api.ts';
import type { FresnelProfileResponse } from '@/services/api.ts';
interface TerrainProfileProps {
start: [number, number]; // [lat, lon]
end: [number, number]; // [lat, lon]
txHeight?: number; // TX antenna height (m)
rxHeight?: number; // RX antenna height (m)
frequency?: number; // Frequency (MHz) for Fresnel calculation
onClose: () => void;
}
const CANVAS_W = 600;
const CANVAS_H = 200;
const CANVAS_H = 220;
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) {
export default function TerrainProfile({
start,
end,
txHeight = 30,
rxHeight = 1.5,
frequency = 1800,
onClose,
}: TerrainProfileProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [profile, setProfile] = useState<TerrainProfilePoint[] | null>(null);
const [fresnelData, setFresnelData] = useState<FresnelProfileResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hover, setHover] = useState<{ x: number; idx: number } | null>(null);
const [showFresnel, setShowFresnel] = useState(true);
// Fetch profile data
// Fetch Fresnel profile data (includes terrain)
useEffect(() => {
setLoading(true);
setError(null);
api
.getTerrainProfile(start[0], start[1], end[0], end[1], 200)
.then((data) => {
setProfile(data);
.getFresnelProfile({
tx_lat: start[0],
tx_lon: start[1],
tx_height_m: txHeight,
rx_lat: end[0],
rx_lon: end[1],
rx_height_m: rxHeight,
frequency_mhz: frequency,
num_points: 200,
})
.then((data: FresnelProfileResponse) => {
setFresnelData(data);
setLoading(false);
})
.catch((err) => {
.catch((err: Error) => {
setError(err.message);
setLoading(false);
});
}, [start, end]);
}, [start, end, txHeight, rxHeight, frequency]);
const profile = fresnelData?.profile;
// Draw chart
const draw = useCallback(
@@ -64,16 +90,24 @@ export default function TerrainProfile({ start, end, onClose }: TerrainProfilePr
// Clear
ctx.clearRect(0, 0, CANVAS_W, CANVAS_H);
const elevations = profile.map((p) => p.elevation);
const terrainElevs = profile.map((p) => p.terrain_elevation);
const losHeights = profile.map((p) => p.los_height);
const fresnelTops = profile.map((p) => p.fresnel_top);
const fresnelBottoms = profile.map((p) => p.fresnel_bottom);
const distances = profile.map((p) => p.distance);
const minElev = Math.min(...elevations);
const maxElev = Math.max(...elevations);
// Calculate bounds including Fresnel zone
const allHeights = showFresnel
? [...terrainElevs, ...fresnelTops, ...fresnelBottoms]
: [...terrainElevs, ...losHeights];
const minElev = Math.min(...allHeights);
const maxElev = Math.max(...allHeights);
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 eMax = maxElev + elevRange * 0.15;
const xScale = (d: number) => PAD.left + (d / maxDist) * PLOT_W;
const yScale = (e: number) => PAD.top + PLOT_H - ((e - eMin) / (eMax - eMin)) * PLOT_H;
@@ -90,11 +124,48 @@ export default function TerrainProfile({ start, end, onClose }: TerrainProfilePr
ctx.stroke();
}
// Fresnel zone fill (light blue)
if (showFresnel) {
ctx.beginPath();
// Top boundary (left to right)
ctx.moveTo(xScale(distances[0]), yScale(fresnelTops[0]));
for (let i = 1; i < profile.length; i++) {
ctx.lineTo(xScale(distances[i]), yScale(fresnelTops[i]));
}
// Bottom boundary (right to left)
for (let i = profile.length - 1; i >= 0; i--) {
ctx.lineTo(xScale(distances[i]), yScale(fresnelBottoms[i]));
}
ctx.closePath();
ctx.fillStyle = 'rgba(59, 130, 246, 0.15)';
ctx.fill();
// Fresnel boundaries (dashed)
ctx.setLineDash([3, 3]);
ctx.strokeStyle = 'rgba(59, 130, 246, 0.4)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(xScale(distances[0]), yScale(fresnelTops[0]));
for (let i = 1; i < profile.length; i++) {
ctx.lineTo(xScale(distances[i]), yScale(fresnelTops[i]));
}
ctx.stroke();
ctx.beginPath();
ctx.moveTo(xScale(distances[0]), yScale(fresnelBottoms[0]));
for (let i = 1; i < profile.length; i++) {
ctx.lineTo(xScale(distances[i]), yScale(fresnelBottoms[i]));
}
ctx.stroke();
ctx.setLineDash([]);
}
// Terrain fill
ctx.beginPath();
ctx.moveTo(xScale(distances[0]), yScale(elevations[0]));
ctx.moveTo(xScale(distances[0]), yScale(terrainElevs[0]));
for (let i = 1; i < profile.length; i++) {
ctx.lineTo(xScale(distances[i]), yScale(elevations[i]));
ctx.lineTo(xScale(distances[i]), yScale(terrainElevs[i]));
}
ctx.lineTo(xScale(distances[distances.length - 1]), PAD.top + PLOT_H);
ctx.lineTo(xScale(distances[0]), PAD.top + PLOT_H);
@@ -102,25 +173,39 @@ export default function TerrainProfile({ start, end, onClose }: TerrainProfilePr
ctx.fillStyle = 'rgba(34, 197, 94, 0.3)';
ctx.fill();
// Highlight Fresnel intrusions (red fill)
if (showFresnel) {
for (let i = 0; i < profile.length; i++) {
if (profile[i].clearance < 0) {
const x = xScale(distances[i]);
const yTerrain = yScale(terrainElevs[i]);
const yFresnel = yScale(fresnelBottoms[i]);
const intrusion = Math.min(yFresnel - yTerrain, 20);
if (intrusion > 0) {
ctx.fillStyle = 'rgba(239, 68, 68, 0.4)';
ctx.fillRect(x - 1, yTerrain, 3, intrusion);
}
}
}
}
// Terrain line
ctx.beginPath();
ctx.moveTo(xScale(distances[0]), yScale(elevations[0]));
ctx.moveTo(xScale(distances[0]), yScale(terrainElevs[0]));
for (let i = 1; i < profile.length; i++) {
ctx.lineTo(xScale(distances[i]), yScale(elevations[i]));
ctx.lineTo(xScale(distances[i]), yScale(terrainElevs[i]));
}
ctx.strokeStyle = '#16a34a';
ctx.lineWidth = 1.5;
ctx.stroke();
// LOS dashed line (start elevation to end elevation)
// LOS line (solid)
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.moveTo(xScale(distances[0]), yScale(losHeights[0]));
ctx.lineTo(xScale(distances[distances.length - 1]), yScale(losHeights[losHeights.length - 1]));
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.setLineDash([]);
// Y axis labels
ctx.fillStyle = '#6b7280';
@@ -147,7 +232,7 @@ export default function TerrainProfile({ start, end, onClose }: TerrainProfilePr
if (hoverIdx !== null && hoverIdx >= 0 && hoverIdx < profile.length) {
const p = profile[hoverIdx];
const hx = xScale(p.distance);
const hy = yScale(p.elevation);
const hy = yScale(p.terrain_elevation);
// Vertical line
ctx.beginPath();
@@ -157,14 +242,15 @@ export default function TerrainProfile({ start, end, onClose }: TerrainProfilePr
ctx.lineWidth = 1;
ctx.stroke();
// Dot
// Dot on terrain
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`;
// Tooltip with clearance info
const clearanceText = showFresnel ? ` | F1: ${p.clearance >= 0 ? '+' : ''}${p.clearance.toFixed(0)}m` : '';
const text = `${Math.round(p.terrain_elevation)}m @ ${(p.distance / 1000).toFixed(2)}km${clearanceText}`;
ctx.font = 'bold 11px monospace';
const tw = ctx.measureText(text).width + 10;
const tx = Math.min(hx + 8, CANVAS_W - tw - 4);
@@ -173,13 +259,13 @@ export default function TerrainProfile({ start, end, onClose }: TerrainProfilePr
ctx.beginPath();
ctx.roundRect(tx, ty, tw, 18, 3);
ctx.fill();
ctx.fillStyle = 'white';
ctx.fillStyle = p.clearance < 0 && showFresnel ? '#fca5a5' : 'white';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, tx + 5, ty + 9);
}
},
[profile]
[profile, showFresnel]
);
// Re-draw on profile load or hover change
@@ -210,12 +296,40 @@ export default function TerrainProfile({ start, end, onClose }: TerrainProfilePr
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;
const minElev = profile ? Math.min(...profile.map((p) => p.terrain_elevation)) : 0;
const maxElev = profile ? Math.max(...profile.map((p) => p.terrain_elevation)) : 0;
const totalDist = fresnelData?.total_distance_m ?? 0;
// Status badge
const getStatusBadge = () => {
if (!fresnelData) return null;
if (fresnelData.los_clear && fresnelData.fresnel_clear) {
return <span className="text-green-600 dark:text-green-400 font-medium">LOS Clear</span>;
} else if (fresnelData.los_clear) {
return (
<span className="text-yellow-600 dark:text-yellow-400 font-medium">
F1 {fresnelData.fresnel_clear_pct}% Clear
</span>
);
} else {
return <span className="text-red-500 font-medium">LOS Blocked</span>;
}
};
// Ref for the container to block Leaflet events
const containerRef = useRef<HTMLDivElement>(null);
// Use Leaflet's DOM event utility to block click propagation to the map
useEffect(() => {
if (containerRef.current) {
L.DomEvent.disableClickPropagation(containerRef.current);
L.DomEvent.disableScrollPropagation(containerRef.current);
}
}, []);
return (
<div
ref={containerRef}
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"
@@ -223,9 +337,20 @@ export default function TerrainProfile({ start, end, onClose }: TerrainProfilePr
>
{/* 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>
<div className="flex items-center gap-3">
<span className="text-xs font-semibold text-gray-700 dark:text-dark-text">
Terrain Profile
</span>
<label className="flex items-center gap-1.5 text-[10px] text-gray-500 cursor-pointer">
<input
type="checkbox"
checked={showFresnel}
onChange={(e) => setShowFresnel(e.target.checked)}
className="w-3 h-3"
/>
Fresnel Zone ({frequency} MHz)
</label>
</div>
<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"
@@ -237,12 +362,12 @@ export default function TerrainProfile({ start, end, onClose }: TerrainProfilePr
{/* Canvas */}
<div className="px-2 py-1">
{loading && (
<div className="flex items-center justify-center h-[200px] text-sm text-gray-400">
<div className="flex items-center justify-center h-[220px] text-sm text-gray-400">
Loading profile...
</div>
)}
{error && (
<div className="flex items-center justify-center h-[200px] text-sm text-red-400">
<div className="flex items-center justify-center h-[220px] text-sm text-red-400">
{error}
</div>
)}
@@ -262,9 +387,17 @@ export default function TerrainProfile({ start, end, onClose }: TerrainProfilePr
<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>
{showFresnel && fresnelData && (
<span>Clearance: {fresnelData.worst_clearance_m.toFixed(0)} m</span>
)}
{getStatusBadge()}
</div>
)}
{/* Recommendation */}
{showFresnel && fresnelData && !fresnelData.fresnel_clear && (
<div className="px-3 py-1.5 text-[10px] bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300 border-t border-yellow-200 dark:border-yellow-800">
{fresnelData.recommendation} (~{fresnelData.estimated_loss_db.toFixed(1)} dB loss)
</div>
)}
</div>

View File

@@ -0,0 +1,669 @@
/**
* WebGL coverage layer using texture-based value interpolation.
*
* Simple approach (like CloudRF surface raster):
* 1. Create texture where each pixel = one grid cell's RSRP value
* 2. GPU's GL_LINEAR filtering interpolates between adjacent cells
* 3. Fragment shader maps interpolated value to color gradient
*/
import { useEffect, useRef, useMemo, useCallback } from 'react';
import { useMap } from 'react-leaflet';
export interface CoveragePoint {
lat: number;
lon: number;
rsrp: number;
}
interface WebGLCoverageLayerProps {
points: CoveragePoint[];
opacity: number;
minRsrp?: number;
maxRsrp?: number;
visible: boolean;
onWebGLFailed?: () => void;
}
const VERTEX_SHADER = `
attribute vec2 a_position;
varying vec2 v_uv;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
// Map position to UV, flip Y
v_uv = vec2((a_position.x + 1.0) * 0.5, 1.0 - (a_position.y + 1.0) * 0.5);
}
`;
// Fragment shader with smoothstep interpolation for C2 continuity
// This removes visible grid edges with minimal performance cost
const FRAGMENT_SHADER = `
precision mediump float;
uniform sampler2D u_coverage;
uniform vec2 u_textureSize;
varying vec2 v_uv;
// Quintic Hermite smoothstep - gives C2 continuity (smooth 2nd derivatives)
// This removes visible "seams" between grid cells
vec4 textureSmooth(sampler2D tex, vec2 uv, vec2 texSize) {
vec2 p = uv * texSize + 0.5;
vec2 i = floor(p);
vec2 f = p - i;
// Quintic hermite curve: f³(6f² - 15f + 10)
f = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
return texture2D(tex, (i + f - 0.5) / texSize);
}
// RSRP to color gradient (red -> orange -> yellow -> green -> cyan)
// Applied AFTER interpolation for clean gradients
vec3 rsrpToColor(float t) {
// t: 0 = weak (red), 1 = strong (cyan)
if (t < 0.25) return mix(vec3(1.0, 0.0, 0.0), vec3(1.0, 0.5, 0.0), t / 0.25);
if (t < 0.5) return mix(vec3(1.0, 0.5, 0.0), vec3(1.0, 1.0, 0.0), (t - 0.25) / 0.25);
if (t < 0.75) return mix(vec3(1.0, 1.0, 0.0), vec3(0.0, 1.0, 0.0), (t - 0.5) / 0.25);
return mix(vec3(0.0, 1.0, 0.0), vec3(0.0, 1.0, 1.0), (t - 0.75) / 0.25);
}
void main() {
// 1. Sample with smoothstep interpolation (RAW RSRP value)
vec4 texel = textureSmooth(u_coverage, v_uv, u_textureSize);
// 2. Alpha channel indicates coverage presence
if (texel.a < 0.1) discard;
// 3. Apply colormap AFTER interpolation (critical for clean gradients)
float rsrp = texel.r;
vec3 color = rsrpToColor(rsrp);
// 4. Smooth boundary fading
float boundaryAlpha = smoothstep(0.01, 0.05, rsrp);
gl_FragColor = vec4(color, boundaryAlpha * 0.85);
}
`;
function compileShader(gl: WebGLRenderingContext, source: string, type: number): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('Shader error:', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
function createProgram(gl: WebGLRenderingContext): WebGLProgram | null {
const vs = compileShader(gl, VERTEX_SHADER, gl.VERTEX_SHADER);
const fs = compileShader(gl, FRAGMENT_SHADER, gl.FRAGMENT_SHADER);
if (!vs || !fs) return null;
const program = gl.createProgram();
if (!program) return null;
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('Program error:', gl.getProgramInfoLog(program));
return null;
}
return program;
}
interface GridInfo {
width: number;
height: number;
minLat: number;
maxLat: number;
minLon: number;
maxLon: number;
latStep: number;
lonStep: number;
}
function detectGrid(points: CoveragePoint[]): GridInfo | null {
if (points.length < 4) return null;
// Calculate bounds directly from points (no rounding)
let minLat = Infinity, maxLat = -Infinity;
let minLon = Infinity, maxLon = -Infinity;
for (const p of points) {
if (p.lat < minLat) minLat = p.lat;
if (p.lat > maxLat) maxLat = p.lat;
if (p.lon < minLon) minLon = p.lon;
if (p.lon > maxLon) maxLon = p.lon;
}
// Find grid step by looking at sorted unique coordinates
const lats = new Set<number>();
const lons = new Set<number>();
for (const p of points) {
lats.add(Math.round(p.lat * 1000000) / 1000000); // 6 decimal places
lons.add(Math.round(p.lon * 1000000) / 1000000);
}
const sortedLats = Array.from(lats).sort((a, b) => a - b);
const sortedLons = Array.from(lons).sort((a, b) => a - b);
// Calculate step from median difference between adjacent points
const latDiffs: number[] = [];
const lonDiffs: number[] = [];
for (let i = 1; i < sortedLats.length; i++) {
latDiffs.push(sortedLats[i] - sortedLats[i-1]);
}
for (let i = 1; i < sortedLons.length; i++) {
lonDiffs.push(sortedLons[i] - sortedLons[i-1]);
}
latDiffs.sort((a, b) => a - b);
lonDiffs.sort((a, b) => a - b);
const latStep = latDiffs[Math.floor(latDiffs.length / 2)] || (maxLat - minLat) / 10;
const lonStep = lonDiffs[Math.floor(lonDiffs.length / 2)] || (maxLon - minLon) / 10;
// Calculate grid dimensions from actual extent and step
const width = Math.max(2, Math.round((maxLon - minLon) / lonStep) + 1);
const height = Math.max(2, Math.round((maxLat - minLat) / latStep) + 1);
return {
width,
height,
minLat,
maxLat,
minLon,
maxLon,
latStep,
lonStep,
};
}
interface TextureResult {
texture: WebGLTexture;
width: number;
height: number;
}
function createCoverageTexture(
gl: WebGLRenderingContext,
points: CoveragePoint[],
grid: GridInfo,
minRsrp: number,
maxRsrp: number
): TextureResult | null {
const { width, height, minLat, maxLat, minLon, maxLon } = grid;
const latRange = maxLat - minLat;
const lonRange = maxLon - minLon;
const rsrpRange = maxRsrp - minRsrp;
// Step 1: Create sparse grid with actual point positions
// Store normalized RSRP value (0-1) at each grid cell that has data
const sparseGrid = new Map<number, number>(); // key = gy * width + gx, value = normalized RSRP
for (const p of points) {
const gx = Math.round((p.lon - minLon) / lonRange * (width - 1));
const gy = Math.round((p.lat - minLat) / latRange * (height - 1));
if (gx >= 0 && gx < width && gy >= 0 && gy < height) {
const normalized = Math.max(0, Math.min(1, (p.rsrp - minRsrp) / rsrpRange));
const key = gy * width + gx;
// Keep the stronger signal if multiple points map to same cell
if (!sparseGrid.has(key) || sparseGrid.get(key)! < normalized) {
sparseGrid.set(key, normalized);
}
}
}
// Step 2: For each empty cell, find nearest filled cell using expanding search
// This fills the circular coverage area properly
const data = new Uint8Array(width * height * 4);
const maxSearchRadius = Math.max(width, height); // Max distance to search
let filledCount = 0;
for (let gy = 0; gy < height; gy++) {
for (let gx = 0; gx < width; gx++) {
const key = gy * width + gx;
if (sparseGrid.has(key)) {
// Cell has actual data
const value = Math.round(sparseGrid.get(key)! * 255);
const idx = key * 4;
data[idx] = value;
data[idx + 1] = 0;
data[idx + 2] = 0;
data[idx + 3] = 255;
filledCount++;
} else {
// Find nearest cell with data using expanding square search
let found = false;
let nearestValue = 0;
let nearestDistSq = Infinity;
// Search in expanding radius
for (let r = 1; r <= maxSearchRadius && !found; r++) {
// Check cells at distance r (square perimeter)
for (let dy = -r; dy <= r && !found; dy++) {
for (let dx = -r; dx <= r; dx++) {
// Only check perimeter cells (optimization)
if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue;
const nx = gx + dx;
const ny = gy + dy;
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
const nkey = ny * width + nx;
if (sparseGrid.has(nkey)) {
const distSq = dx * dx + dy * dy;
if (distSq < nearestDistSq) {
nearestDistSq = distSq;
nearestValue = sparseGrid.get(nkey)!;
}
}
}
}
// If we found something at this radius, use it (nearest neighbor)
if (nearestDistSq < Infinity) {
found = true;
}
}
if (found) {
// Fill with nearest neighbor value
// Apply distance-based alpha fade for smooth edges
const dist = Math.sqrt(nearestDistSq);
const maxDist = 3; // Fade out over 3 cells
const alpha = dist <= maxDist ? 255 : Math.max(0, 255 - (dist - maxDist) * 50);
const value = Math.round(nearestValue * 255);
const idx = key * 4;
data[idx] = value;
data[idx + 1] = 0;
data[idx + 2] = 0;
data[idx + 3] = Math.round(alpha);
filledCount++;
}
// If not found, leave as transparent (alpha = 0)
}
}
}
console.log('[WebGL] Texture created (nearest-neighbor filled):', {
textureSize: `${width}x${height}`,
originalPoints: sparseGrid.size,
filledCells: filledCount,
totalCells: width * height,
fillPercent: (filledCount / (width * height) * 100).toFixed(1) + '%'
});
const texture = gl.createTexture();
if (!texture) return null;
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
// LINEAR filtering for smooth interpolation between filled cells
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return { texture, width, height };
}
export default function WebGLCoverageLayer({
points,
opacity,
minRsrp = -130,
maxRsrp = -50,
visible,
onWebGLFailed,
}: WebGLCoverageLayerProps) {
const map = useMap();
// Refs for WebGL resources
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const glRef = useRef<WebGLRenderingContext | null>(null);
const programRef = useRef<WebGLProgram | null>(null);
const textureRef = useRef<WebGLTexture | null>(null);
const quadBufferRef = useRef<WebGLBuffer | null>(null);
// Track what data the current texture was built from
const lastPointsHashRef = useRef<string>('');
const boundsRef = useRef<{ minLat: number; maxLat: number; minLon: number; maxLon: number } | null>(null);
const textureSizeRef = useRef<{ width: number; height: number }>({ width: 1, height: 1 });
// Stable ref for callback to avoid re-initialization
const onWebGLFailedRef = useRef(onWebGLFailed);
onWebGLFailedRef.current = onWebGLFailed;
// Track if initialized to prevent re-runs
const initializedRef = useRef(false);
// Compute stable hash for points data
const pointsHash = useMemo(() => {
if (points.length === 0) return '';
const first = points[0];
const last = points[points.length - 1];
return `${points.length}:${first.lat.toFixed(5)}:${last.lon.toFixed(5)}:${first.rsrp.toFixed(1)}`;
}, [points]);
// Render function - only draws, no resource creation
const render = useCallback(() => {
const canvas = canvasRef.current;
const gl = glRef.current;
const program = programRef.current;
const texture = textureRef.current;
const bounds = boundsRef.current;
// DEBUG: Check what's missing if we can't render
if (!canvas || !gl || !program || !texture || !bounds) {
console.log('[WebGL] Render skipped - missing:', {
canvas: !!canvas,
gl: !!gl,
program: !!program,
texture: !!texture,
bounds: !!bounds
});
return;
}
// Position canvas over coverage area
const nw = map.latLngToLayerPoint([bounds.maxLat, bounds.minLon]);
const se = map.latLngToLayerPoint([bounds.minLat, bounds.maxLon]);
const width = Math.abs(se.x - nw.x);
const height = Math.abs(se.y - nw.y);
if (width < 1 || height < 1) return;
canvas.style.transform = `translate(${nw.x}px, ${nw.y}px)`;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
// DEBUG: Log every reposition
console.log('[WebGL] Canvas repositioned:', {
transform: canvas.style.transform,
width: canvas.style.width,
height: canvas.style.height,
zoom: map.getZoom()
});
// Get texture size for shader uniform
const texSize = textureSizeRef.current;
// Set canvas resolution
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const canvasW = Math.min(Math.round(width * dpr), 2048);
const canvasH = Math.min(Math.round(height * dpr), 2048);
if (canvas.width !== canvasW || canvas.height !== canvasH) {
canvas.width = canvasW;
canvas.height = canvasH;
}
// Render
gl.viewport(0, 0, canvasW, canvasH);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
// Bind quad buffer
gl.bindBuffer(gl.ARRAY_BUFFER, quadBufferRef.current);
const posLoc = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(posLoc);
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
// Bind texture
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(gl.getUniformLocation(program, 'u_coverage'), 0);
// Set texture size uniform (texSize already defined above for blur)
const textureSizeLocation = gl.getUniformLocation(program, 'u_textureSize');
if (textureSizeLocation) {
gl.uniform2f(textureSizeLocation, texSize.width, texSize.height);
}
// Draw
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
gl.disableVertexAttribArray(posLoc);
}, [map]);
// Effect 1: Initialize WebGL (canvas, context, program, quad buffer) - runs ONCE
useEffect(() => {
if (!visible) return;
// Skip if already initialized
if (initializedRef.current && canvasRef.current && glRef.current) {
return;
}
const pane = map.getPane('overlayPane');
if (!pane) return;
// Create canvas if needed
if (!canvasRef.current) {
// Remove any leftover canvas elements from previous sessions
const existingCanvases = pane.querySelectorAll('canvas.webgl-coverage');
existingCanvases.forEach(c => c.remove());
console.log('[WebGL] Removed', existingCanvases.length, 'leftover canvas elements');
const canvas = document.createElement('canvas');
canvas.className = 'webgl-coverage'; // Add class for identification
canvas.style.position = 'absolute';
canvas.style.pointerEvents = 'none';
canvas.style.transformOrigin = '0 0';
pane.appendChild(canvas);
canvasRef.current = canvas;
}
const canvas = canvasRef.current;
// Initialize WebGL if needed
if (!glRef.current) {
const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false });
if (!gl) {
console.error('[WebGL] WebGL not available');
onWebGLFailedRef.current?.();
return;
}
glRef.current = gl;
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
}
const gl = glRef.current;
// Create program if needed
if (!programRef.current) {
const program = createProgram(gl);
if (!program) {
console.error('[WebGL] Failed to create program');
onWebGLFailedRef.current?.();
return;
}
programRef.current = program;
}
// Create quad buffer if needed
if (!quadBufferRef.current) {
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, -1, 1, 1, 1
]), gl.STATIC_DRAW);
quadBufferRef.current = buf;
}
initializedRef.current = true;
console.log('[WebGL] Initialized (should appear ONCE)');
}, [visible, map]); // Removed onWebGLFailed - use ref instead
// Effect 2: Create texture when points data changes
useEffect(() => {
if (!visible || points.length === 0 || !glRef.current) return;
// Skip if same data
if (pointsHash === lastPointsHashRef.current && textureRef.current) {
return;
}
const gl = glRef.current;
const grid = detectGrid(points);
if (!grid) return;
// Delete old texture
if (textureRef.current) {
gl.deleteTexture(textureRef.current);
textureRef.current = null;
}
// Create new texture (returns texture + dimensions)
const result = createCoverageTexture(gl, points, grid, minRsrp, maxRsrp);
if (!result) {
console.error('[WebGL] Failed to create texture');
return;
}
textureRef.current = result.texture;
lastPointsHashRef.current = pointsHash;
// Store texture size for shader uniform
textureSizeRef.current = { width: result.width, height: result.height };
// Store bounds for rendering (with half-cell padding)
const canvasBounds = {
minLat: grid.minLat - grid.latStep / 2,
maxLat: grid.maxLat + grid.latStep / 2,
minLon: grid.minLon - grid.lonStep / 2,
maxLon: grid.maxLon + grid.lonStep / 2,
};
boundsRef.current = canvasBounds;
// FULL DEBUG: Compare data extent vs canvas bounds
const lats = points.map(p => p.lat);
const lons = points.map(p => p.lon);
const dataMinLat = Math.min(...lats);
const dataMaxLat = Math.max(...lats);
const dataMinLon = Math.min(...lons);
const dataMaxLon = Math.max(...lons);
console.log('[WebGL] FULL DEBUG:', {
// Data extent (actual points)
dataMinLat: dataMinLat.toFixed(6),
dataMaxLat: dataMaxLat.toFixed(6),
dataMinLon: dataMinLon.toFixed(6),
dataMaxLon: dataMaxLon.toFixed(6),
dataLatRange: (dataMaxLat - dataMinLat).toFixed(6),
dataLonRange: (dataMaxLon - dataMinLon).toFixed(6),
// Grid detection result
gridWidth: grid.width,
gridHeight: grid.height,
gridMinLat: grid.minLat.toFixed(6),
gridMaxLat: grid.maxLat.toFixed(6),
gridMinLon: grid.minLon.toFixed(6),
gridMaxLon: grid.maxLon.toFixed(6),
gridLatStep: grid.latStep.toFixed(6),
gridLonStep: grid.lonStep.toFixed(6),
// Texture size
textureWidth: result.width,
textureHeight: result.height,
// Canvas bounds (what we use for rendering)
canvasMinLat: canvasBounds.minLat.toFixed(6),
canvasMaxLat: canvasBounds.maxLat.toFixed(6),
canvasMinLon: canvasBounds.minLon.toFixed(6),
canvasMaxLon: canvasBounds.maxLon.toFixed(6),
canvasLatRange: (canvasBounds.maxLat - canvasBounds.minLat).toFixed(6),
canvasLonRange: (canvasBounds.maxLon - canvasBounds.minLon).toFixed(6),
// Comparison
latCoveragePercent: ((canvasBounds.maxLat - canvasBounds.minLat) / (dataMaxLat - dataMinLat) * 100).toFixed(1) + '%',
lonCoveragePercent: ((canvasBounds.maxLon - canvasBounds.minLon) / (dataMaxLon - dataMinLon) * 100).toFixed(1) + '%',
// Expected
expectedRange: '~0.18 degrees for 20km radius',
pointCount: points.length
});
// Initial render
render();
}, [visible, points, pointsHash, minRsrp, maxRsrp, render]);
// Effect 3: Set up map event listeners for re-rendering on move/zoom
// Note: Set up listeners even without texture - render() will check for texture
useEffect(() => {
if (!visible) return;
let frameId = 0;
let moveCount = 0;
const onMapChange = () => {
moveCount++;
if (moveCount <= 3 || moveCount % 10 === 0) {
console.log('[WebGL] Map event #' + moveCount + ', triggering render');
}
cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(render);
};
map.on('move', onMapChange);
map.on('zoom', onMapChange);
map.on('resize', onMapChange);
console.log('[WebGL] Map listeners attached');
return () => {
map.off('move', onMapChange);
map.off('zoom', onMapChange);
map.off('resize', onMapChange);
cancelAnimationFrame(frameId);
console.log('[WebGL] Map listeners detached');
};
}, [visible, map, render]);
// Effect 4: Update opacity without recreating anything
useEffect(() => {
if (canvasRef.current) {
canvasRef.current.style.opacity = String(opacity);
}
}, [opacity]);
// Effect 5: Hide/show canvas based on visibility
useEffect(() => {
if (canvasRef.current) {
canvasRef.current.style.display = visible ? 'block' : 'none';
}
}, [visible]);
// Cleanup on unmount
useEffect(() => {
return () => {
const gl = glRef.current;
if (gl) {
if (textureRef.current) gl.deleteTexture(textureRef.current);
if (quadBufferRef.current) gl.deleteBuffer(quadBufferRef.current);
if (programRef.current) gl.deleteProgram(programRef.current);
}
if (canvasRef.current) {
canvasRef.current.remove();
canvasRef.current = null;
}
glRef.current = null;
programRef.current = null;
textureRef.current = null;
quadBufferRef.current = null;
};
}, []);
return null;
}

View File

@@ -0,0 +1,361 @@
/**
* Link Budget Calculator Panel
*
* Shows complete RF link budget from transmitter to receiver:
* - TX: power, gain, cable loss, EIRP
* - Path: distance, FSPL, terrain loss
* - RX: gain, sensitivity, margin
*/
import { useState, useEffect } from 'react';
import { useSitesStore } from '@/store/sites.ts';
import { api } from '@/services/api.ts';
import type { LinkBudgetResponse } from '@/services/api.ts';
import Button from '@/components/ui/Button.tsx';
interface LinkBudgetPanelProps {
/** Optional RX coordinates from map click */
rxPoint?: { lat: number; lon: number } | null;
/** Callback to enable map click mode */
onRequestMapClick?: () => void;
/** Callback when panel is closed */
onClose?: () => void;
}
export default function LinkBudgetPanel({
rxPoint,
onRequestMapClick,
onClose,
}: LinkBudgetPanelProps) {
const sites = useSitesStore((s) => s.sites);
const selectedSiteId = useSitesStore((s) => s.selectedSiteId);
// TX parameters (from selected site or manual)
const selectedSite = sites.find((s) => s.id === selectedSiteId);
// TX height override for what-if scenarios (null = use site default)
const [txHeightOverride, setTxHeightOverride] = useState<number | null>(null);
const txHeight = txHeightOverride ?? selectedSite?.height ?? 30;
// Reset height override when site changes
useEffect(() => {
setTxHeightOverride(null);
}, [selectedSiteId]);
// RX coordinates
const [rxLat, setRxLat] = useState<string>(rxPoint?.lat?.toFixed(6) || '');
const [rxLon, setRxLon] = useState<string>(rxPoint?.lon?.toFixed(6) || '');
// Additional TX/RX parameters
const [txCableLoss, setTxCableLoss] = useState<number>(2);
const [rxGain, setRxGain] = useState<number>(0);
const [rxCableLoss, setRxCableLoss] = useState<number>(0);
const [rxSensitivity, setRxSensitivity] = useState<number>(-100);
const [rxHeight, setRxHeight] = useState<number>(1.5);
// Result
const [result, setResult] = useState<LinkBudgetResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Update RX coordinates when rxPoint changes
useEffect(() => {
if (rxPoint) {
setRxLat(rxPoint.lat.toFixed(6));
setRxLon(rxPoint.lon.toFixed(6));
}
}, [rxPoint]);
const calculateLinkBudget = async () => {
if (!selectedSite) {
setError('Select a site first');
return;
}
const rxLatNum = parseFloat(rxLat);
const rxLonNum = parseFloat(rxLon);
if (isNaN(rxLatNum) || isNaN(rxLonNum)) {
setError('Enter valid RX coordinates');
return;
}
setLoading(true);
setError(null);
try {
const response = await api.calculateLinkBudget({
tx_lat: selectedSite.lat,
tx_lon: selectedSite.lon,
tx_power_dbm: selectedSite.power,
tx_gain_dbi: selectedSite.gain,
tx_cable_loss_db: txCableLoss,
tx_height_m: txHeight,
rx_lat: rxLatNum,
rx_lon: rxLonNum,
rx_gain_dbi: rxGain,
rx_cable_loss_db: rxCableLoss,
rx_sensitivity_dbm: rxSensitivity,
rx_height_m: rxHeight,
frequency_mhz: selectedSite.frequency,
});
setResult(response);
} catch (err) {
setError(err instanceof Error ? err.message : 'Calculation failed');
} finally {
setLoading(false);
}
};
const marginColor = result
? result.margin_db >= 10
? 'text-green-600 dark:text-green-400'
: result.margin_db >= 0
? 'text-yellow-600 dark:text-yellow-400'
: 'text-red-600 dark:text-red-400'
: '';
return (
<div
className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4 space-y-4 w-80"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text flex items-center gap-2">
<span className="text-lg">📡</span>
Link Budget Calculator
</h3>
{onClose && (
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-white text-sm"
>
</button>
)}
</div>
{/* TX Section */}
<div className="space-y-2">
<div className="text-xs font-medium text-gray-500 dark:text-dark-muted uppercase">
Transmitter
</div>
{selectedSite ? (
<div className="text-xs space-y-1 bg-gray-50 dark:bg-dark-bg p-2 rounded text-gray-700 dark:text-dark-text">
<div className="flex justify-between">
<span className="text-gray-500 dark:text-dark-muted">Site:</span>
<span className="font-medium">{selectedSite.name}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500 dark:text-dark-muted">Power:</span>
<span>{selectedSite.power} dBm</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500 dark:text-dark-muted">Gain:</span>
<span>{selectedSite.gain} dBi</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-500 dark:text-dark-muted">Height:</span>
<div className="flex items-center">
<input
type="number"
value={txHeight}
onChange={(e) => setTxHeightOverride(parseFloat(e.target.value) || 30)}
className="w-16 text-right text-xs px-1 py-0.5 border rounded dark:bg-dark-bg dark:border-dark-border dark:text-dark-text"
min="1"
max="300"
step="1"
/>
<span className="text-gray-400 dark:text-dark-muted ml-1">m</span>
</div>
</div>
<div className="flex justify-between">
<span className="text-gray-500 dark:text-dark-muted">Frequency:</span>
<span>{selectedSite.frequency} MHz</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-500 dark:text-dark-muted">Cable Loss:</span>
<input
type="number"
value={txCableLoss}
onChange={(e) => setTxCableLoss(parseFloat(e.target.value) || 0)}
className="w-16 text-right text-xs px-1 py-0.5 border rounded dark:bg-dark-bg dark:border-dark-border dark:text-dark-text"
step="0.5"
/>
<span className="text-gray-400 dark:text-dark-muted ml-1">dB</span>
</div>
</div>
) : (
<div className="text-xs text-gray-400 dark:text-dark-muted italic">Select a site on the map</div>
)}
</div>
{/* RX Section */}
<div className="space-y-2">
<div className="text-xs font-medium text-gray-500 dark:text-dark-muted uppercase">
Receiver
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-[10px] text-gray-400 dark:text-dark-muted">Latitude</label>
<input
type="text"
value={rxLat}
onChange={(e) => setRxLat(e.target.value)}
placeholder="48.4500"
className="w-full text-xs px-2 py-1 border rounded dark:bg-dark-bg dark:border-dark-border text-gray-800 dark:text-dark-text"
/>
</div>
<div>
<label className="text-[10px] text-gray-400 dark:text-dark-muted">Longitude</label>
<input
type="text"
value={rxLon}
onChange={(e) => setRxLon(e.target.value)}
placeholder="35.0400"
className="w-full text-xs px-2 py-1 border rounded dark:bg-dark-bg dark:border-dark-border text-gray-800 dark:text-dark-text"
/>
</div>
</div>
{onRequestMapClick && (
<Button size="sm" variant="secondary" onClick={onRequestMapClick} className="w-full">
📍 Click on Map to Set RX Point
</Button>
)}
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<label className="text-[10px] text-gray-400 dark:text-dark-muted">RX Gain (dBi)</label>
<input
type="number"
value={rxGain}
onChange={(e) => setRxGain(parseFloat(e.target.value) || 0)}
className="w-full px-2 py-1 border rounded dark:bg-dark-bg dark:border-dark-border text-gray-800 dark:text-dark-text"
/>
</div>
<div>
<label className="text-[10px] text-gray-400 dark:text-dark-muted">RX Height (m)</label>
<input
type="number"
value={rxHeight}
onChange={(e) => setRxHeight(parseFloat(e.target.value) || 1.5)}
className="w-full px-2 py-1 border rounded dark:bg-dark-bg dark:border-dark-border text-gray-800 dark:text-dark-text"
/>
</div>
<div>
<label className="text-[10px] text-gray-400 dark:text-dark-muted">Sensitivity (dBm)</label>
<input
type="number"
value={rxSensitivity}
onChange={(e) => setRxSensitivity(parseFloat(e.target.value) || -100)}
className="w-full px-2 py-1 border rounded dark:bg-dark-bg dark:border-dark-border text-gray-800 dark:text-dark-text"
/>
</div>
<div>
<label className="text-[10px] text-gray-400 dark:text-dark-muted">Cable Loss (dB)</label>
<input
type="number"
value={rxCableLoss}
onChange={(e) => setRxCableLoss(parseFloat(e.target.value) || 0)}
className="w-full px-2 py-1 border rounded dark:bg-dark-bg dark:border-dark-border text-gray-800 dark:text-dark-text"
/>
</div>
</div>
</div>
{/* Calculate Button */}
<Button
onClick={calculateLinkBudget}
disabled={loading || !selectedSite}
className="w-full"
>
{loading ? 'Calculating...' : 'Calculate Link Budget'}
</Button>
{/* Error */}
{error && (
<div className="text-xs text-red-500 bg-red-50 dark:bg-red-900/20 p-2 rounded">
{error}
</div>
)}
{/* Results */}
{result && (
<div className="space-y-2 border-t pt-3 dark:border-dark-border">
<div className="text-xs font-medium text-gray-500 dark:text-dark-muted uppercase">
Results
</div>
{/* Path Info */}
<div className="text-xs space-y-1 bg-gray-50 dark:bg-dark-bg p-2 rounded text-gray-700 dark:text-dark-text">
<div className="flex justify-between">
<span className="text-gray-500 dark:text-dark-muted">Distance:</span>
<span className="font-medium">{result.distance_km.toFixed(2)} km</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500 dark:text-dark-muted">LOS:</span>
<span className={result.los_clear ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}>
{result.los_clear ? '✓ Clear' : '✗ Blocked'}
</span>
</div>
</div>
{/* Link Budget Table */}
<div className="text-xs space-y-1 bg-blue-50 dark:bg-blue-900/20 p-2 rounded text-gray-700 dark:text-dark-text">
<div className="flex justify-between">
<span>EIRP:</span>
<span className="font-mono">{result.eirp_dbm.toFixed(1)} dBm</span>
</div>
<div className="flex justify-between text-gray-500 dark:text-dark-muted">
<span>- FSPL:</span>
<span className="font-mono">{result.fspl_db.toFixed(1)} dB</span>
</div>
<div className="flex justify-between text-gray-500 dark:text-dark-muted">
<span>- Terrain Loss:</span>
<span className="font-mono">{result.terrain_loss_db.toFixed(1)} dB</span>
</div>
<div className="flex justify-between border-t pt-1 dark:border-dark-border">
<span>= Total Path Loss:</span>
<span className="font-mono font-medium">{result.total_path_loss_db.toFixed(1)} dB</span>
</div>
</div>
{/* Final Result */}
<div className="text-xs space-y-1 bg-gray-100 dark:bg-dark-border p-2 rounded text-gray-700 dark:text-dark-text">
<div className="flex justify-between">
<span>Received Power:</span>
<span className="font-mono font-medium">{result.rx_power_dbm.toFixed(1)} dBm</span>
</div>
<div className="flex justify-between">
<span>RX Sensitivity:</span>
<span className="font-mono">{rxSensitivity} dBm</span>
</div>
<div className={`flex justify-between font-bold ${marginColor}`}>
<span>Link Margin:</span>
<span className="font-mono">{result.margin_db.toFixed(1)} dB</span>
</div>
<div className={`text-center text-sm font-bold mt-2 ${marginColor}`}>
{result.status === 'OK' ? '✓ LINK OK' : '✗ LINK FAIL'}
</div>
</div>
{/* Obstructions */}
{result.obstructions && result.obstructions.length > 0 && (
<div className="text-xs text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20 p-2 rounded">
<div className="font-medium mb-1"> Terrain Obstructions:</div>
{result.obstructions.slice(0, 3).map((obs, i) => (
<div key={i}>
@ {(obs.distance_m / 1000).toFixed(2)} km: +{obs.height_above_los_m.toFixed(1)} m above LOS
</div>
))}
{result.obstructions.length > 3 && (
<div className="text-gray-500">...and {result.obstructions.length - 3} more</div>
)}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useMemo } from 'react';
import type { Site } from '@/types/index.ts';
import { useSitesStore } from '@/store/sites.ts';
import { useToolStore } from '@/store/tools.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
import Button from '@/components/ui/Button.tsx';
import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx';
@@ -75,9 +76,20 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
const deleteSite = useSitesStore((s) => s.deleteSite);
const selectSite = useSitesStore((s) => s.selectSite);
const selectedSiteId = useSitesStore((s) => s.selectedSiteId);
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
const togglePlacingMode = useSitesStore((s) => s.togglePlacingMode);
const selectedSiteIds = useSitesStore((s) => s.selectedSiteIds);
// Tool store for site placement mode
const activeTool = useToolStore((s) => s.activeTool);
const setActiveTool = useToolStore((s) => s.setActiveTool);
const clearTool = useToolStore((s) => s.clearTool);
const isPlacingMode = activeTool === 'site-placement';
const togglePlacingMode = useCallback(() => {
if (isPlacingMode) {
clearTool();
} else {
setActiveTool('site-placement');
}
}, [isPlacingMode, setActiveTool, clearTool]);
const toggleSiteSelection = useSitesStore((s) => s.toggleSiteSelection);
const selectAllSites = useSitesStore((s) => s.selectAllSites);
const clearSelection = useSitesStore((s) => s.clearSelection);

View File

@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { useSitesStore } from '@/store/sites.ts';
import { useCoverageStore } from '@/store/coverage.ts';
import { useSettingsStore } from '@/store/settings.ts';
import { useToolStore } from '@/store/tools.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
interface ShortcutHandlers {
@@ -63,7 +64,7 @@ export function useKeyboardShortcuts({
// Escape always works
if (e.key === 'Escape') {
useSitesStore.getState().selectSite(null);
useSitesStore.getState().setPlacingMode(false);
useToolStore.getState().clearTool();
onCloseForm();
return;
}
@@ -76,7 +77,7 @@ export function useKeyboardShortcuts({
switch (e.key.toUpperCase()) {
case 'S': // Shift+S: New site (place mode)
e.preventDefault();
useSitesStore.getState().setPlacingMode(true);
useToolStore.getState().setActiveTool('site-placement');
useToastStore.getState().addToast('Click on map to place new site', 'info');
return;
case 'C': // Shift+C: Clear coverage

View File

@@ -35,6 +35,31 @@
width: 100%;
height: 100%;
z-index: 0;
cursor: default !important;
}
/* Remove grab cursor from interactive layers */
.leaflet-interactive {
cursor: default !important;
}
/* Grabbing only when actually dragging */
.leaflet-container.leaflet-dragging,
.leaflet-container:active {
cursor: grabbing !important;
}
/* Tool-specific cursors (applied via JS class toggle) */
.leaflet-container.tool-ruler {
cursor: crosshair !important;
}
.leaflet-container.tool-rx-placement {
cursor: crosshair !important;
}
.leaflet-container.tool-site-placement {
cursor: cell !important;
}
/* Dark mode map tiles (invert brightness slightly) */

View File

@@ -271,6 +271,51 @@ class ApiService {
const data = await response.json();
return data.profile ?? data;
}
// === Link Budget API ===
async calculateLinkBudget(request: LinkBudgetRequest): Promise<LinkBudgetResponse> {
const response = await fetch(`${API_BASE}/api/coverage/link-budget`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Link budget calculation failed' }));
throw new Error(error.detail || 'Link budget calculation failed');
}
return response.json();
}
// === Fresnel Profile API ===
async getFresnelProfile(request: FresnelProfileRequest): Promise<FresnelProfileResponse> {
const response = await fetch(`${API_BASE}/api/coverage/fresnel-profile`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Fresnel profile calculation failed' }));
throw new Error(error.detail || 'Fresnel profile calculation failed');
}
return response.json();
}
// === Interference API ===
async calculateInterference(request: CoverageRequest): Promise<InterferenceResponse> {
const response = await fetch(`${API_BASE}/api/coverage/interference`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Interference calculation failed' }));
throw new Error(error.detail || 'Interference calculation failed');
}
return response.json();
}
}
// === Region types ===
@@ -328,4 +373,113 @@ export interface TerrainProfilePoint {
distance: number;
}
// === Link Budget types ===
export interface LinkBudgetRequest {
tx_lat: number;
tx_lon: number;
tx_power_dbm: number;
tx_gain_dbi: number;
tx_cable_loss_db: number;
tx_height_m: number;
rx_lat: number;
rx_lon: number;
rx_gain_dbi: number;
rx_cable_loss_db: number;
rx_sensitivity_dbm: number;
rx_height_m: number;
frequency_mhz: number;
}
export interface LinkBudgetResponse {
distance_km: number;
distance_m: number;
tx_elevation_m: number;
rx_elevation_m: number;
eirp_dbm: number;
fspl_db: number;
terrain_loss_db: number;
total_path_loss_db: number;
los_clear: boolean;
obstructions: { distance_m: number; height_above_los_m: number }[];
rx_power_dbm: number;
margin_db: number;
status: 'OK' | 'FAIL';
link_budget: {
tx_power_dbm: number;
tx_gain_dbi: number;
tx_cable_loss_db: number;
rx_gain_dbi: number;
rx_cable_loss_db: number;
rx_sensitivity_dbm: number;
};
}
// === Fresnel Profile types ===
export interface FresnelProfileRequest {
tx_lat: number;
tx_lon: number;
tx_height_m: number;
rx_lat: number;
rx_lon: number;
rx_height_m: number;
frequency_mhz: number;
num_points?: number;
}
export interface FresnelProfilePoint {
distance: number;
lat: number;
lon: number;
terrain_elevation: number;
los_height: number;
fresnel_top: number;
fresnel_bottom: number;
f1_radius: number;
clearance: number;
}
export interface FresnelProfileResponse {
profile: FresnelProfilePoint[];
total_distance_m: number;
tx_elevation: number;
rx_elevation: number;
frequency_mhz: number;
wavelength_m: number;
los_clear: boolean;
fresnel_clear: boolean;
fresnel_clear_pct: number;
worst_clearance_m: number;
estimated_loss_db: number;
recommendation: string;
}
// === Interference types ===
export interface InterferencePoint {
lat: number;
lon: number;
ci_ratio_db: number;
best_server_idx: number;
best_server_rsrp: number;
}
export interface InterferenceResponse {
points: InterferencePoint[];
count: number;
stats: {
min_ci_db: number;
max_ci_db: number;
avg_ci_db: number;
good_coverage_pct: number;
marginal_coverage_pct: number;
interference_dominant_pct: number;
};
computation_time: number;
sites: { name: string; frequency_mhz: number }[];
frequency_groups: Record<number, number>;
warning: string | null;
}
export const api = new ApiService();

View File

@@ -13,6 +13,7 @@ interface SettingsState {
showBoundary: boolean;
showElevationOverlay: boolean;
elevationOpacity: number;
useWebGLCoverage: boolean;
setTheme: (theme: Theme) => void;
setShowBoundary: (show: boolean) => void;
setShowTerrain: (show: boolean) => void;
@@ -22,6 +23,7 @@ interface SettingsState {
setShowElevationInfo: (show: boolean) => void;
setShowElevationOverlay: (show: boolean) => void;
setElevationOpacity: (opacity: number) => void;
setUseWebGLCoverage: (use: boolean) => void;
}
function applyTheme(theme: Theme) {
@@ -47,6 +49,7 @@ export const useSettingsStore = create<SettingsState>()(
showBoundary: false,
showElevationOverlay: false,
elevationOpacity: 0.5,
useWebGLCoverage: true, // Default to WebGL smooth rendering
setTheme: (theme: Theme) => {
set({ theme });
applyTheme(theme);
@@ -59,9 +62,19 @@ export const useSettingsStore = create<SettingsState>()(
setShowBoundary: (show: boolean) => set({ showBoundary: show }),
setShowElevationOverlay: (show: boolean) => set({ showElevationOverlay: show }),
setElevationOpacity: (opacity: number) => set({ elevationOpacity: opacity }),
setUseWebGLCoverage: (use: boolean) => set({ useWebGLCoverage: use }),
}),
{
name: 'rfcp-settings',
version: 2, // Bump version to reset useWebGLCoverage to true
migrate: (persistedState: unknown, version: number) => {
const state = persistedState as Partial<SettingsState>;
if (version < 2) {
// v2: Reset useWebGLCoverage to true (was stuck on false from early WebGL failures)
state.useWebGLCoverage = true;
}
return state as SettingsState;
},
}
)
);

View File

@@ -0,0 +1,26 @@
/**
* Tool Mode Store
*
* Single source of truth for which tool is currently active.
* Only the active tool receives map click events.
*/
import { create } from 'zustand';
export type ActiveTool =
| 'none' // Default — pan/zoom only, no click actions
| 'ruler' // Distance measurement, click to add points
| 'rx-placement' // Link Budget RX point, single click
| 'site-placement'; // Place new site on map
interface ToolState {
activeTool: ActiveTool;
setActiveTool: (tool: ActiveTool) => void;
clearTool: () => void;
}
export const useToolStore = create<ToolState>((set) => ({
activeTool: 'none',
setActiveTool: (tool) => set({ activeTool: tool }),
clearTool: () => set({ activeTool: 'none' }),
}));