@mytec: WebGL works
This commit is contained in:
27
frontend/package-lock.json
generated
27
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
83
frontend/src/components/map/LinkBudgetOverlay.tsx
Normal file
83
frontend/src/components/map/LinkBudgetOverlay.tsx
Normal 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);
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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='© <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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
669
frontend/src/components/map/WebGLCoverageLayer.tsx
Normal file
669
frontend/src/components/map/WebGLCoverageLayer.tsx
Normal 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;
|
||||
}
|
||||
361
frontend/src/components/panels/LinkBudgetPanel.tsx
Normal file
361
frontend/src/components/panels/LinkBudgetPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
26
frontend/src/store/tools.ts
Normal file
26
frontend/src/store/tools.ts
Normal 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' }),
|
||||
}));
|
||||
Reference in New Issue
Block a user