diff --git a/README.md b/README.md index 73416d7..dfdf87c 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,70 @@ -# MeshRF +# meshRF 📡 -A modern, web-based RF simulation and link analysis tool designed for LoRa Mesh networks (Meshtastic, Reticulum, etc.). Built with React, Leaflet, and standard geospatial libraries. +A professional-grade RF propagation and link analysis tool designed for LoRa Mesh networks (Meshtastic, Reticulum, Sidewinder). Built with **React**, **Leaflet**, and a high-fidelity **Geodetic Physics Engine**. ![Link Analysis Demo](./public/meshrf-preview.png) -## Features +## ✨ Features -### 📡 Link Analysis +### 📡 Advanced Link Analysis -- **Point-to-Point Analysis**: Click any two points on the map to instantly calculate link feasibility. -- **Link Budget Calculator**: Real-time RSSI, SNR, and Link Margin calculations based on TX power, antenna gain, and frequency. -- **Fresnel Zone Visualization**: Visualizes the 1st Fresnel Zone to help identify obstructions. -- **Line of Sight (LOS)**: Draws direct LOS paths with color-coding (Green/Yellow/Red) based on link quality. +- **Geodetic Physics Engine**: Calculates **Earth Bulge** and effective terrain height based on link distance and configurable **K-Factor**. +- **WISP-Grade Quality**: Evaluates links using the strict **60% Fresnel Zone Clearance** rule (Excellent/Good/Marginal/Obstructed). +- **Multi-Variable Profile**: Visualizes Terrain, Earth Curvature, Line of Sight (LOS), and Fresnel Zones on a dynamic 2D chart. +- **Clutter Awareness**: Simulates signal loss through trees or urban "clutter" layers. -### 🏔️ Terrain Awareness +### ⚡ Batch Operations -- **Elevation Profiles**: Fetches global elevation data (via Open-Meteo) to generate accurate path profiles. -- **Obstruction Detection**: Automatically detects terrain that blocks the LOS or encroaches on the Fresnel zone. -- **3D-Like Topography**: "Google Terrain" style maps for intuitive planning. +- **Bulk Link Matrix**: Import a simple CSV of nodes (`Name, Lat, Lon`) and instantly compute link budgets for every possible pair. +- **Automated Reporting**: Export detailed CSV reports containing RSSI, Signal Margin, and Clearance values for hundreds of potential links. -### 🛠️ Configurable Hardware +### 🛠️ Hardware Simulation -- **Device Presets**: Pre-configured profiles for popular hardware (Heltec V3, T-Beam, RAK4631, etc.). -- **Antenna Options**: Select from standard antennas (Stubby, Dipole, Yagi) or enter custom gain. -- **Radio Settings**: Adjust Spreading Factor (SF), Bandwidth (BW), and Coding Rate (CR) to simulate different LoRa config (LongFast, ShortFast, etc.). +- **Device Presets**: Pre-loaded specs for popular mesh hardware (Heltec V3, T-Beam, RAK4631, Station G2). +- **Radio Config**: Adjust Spreading Factor (SF), Bandwidth (BW), and Coding Rate (CR) to simulate real-world LoRa modulation (LongFast, ShortFast). +- **Antenna Modeling**: Select standard antennas (Stubby, Dipole, Yagi) or input custom gain figures. -### 🌍 Application Features metrics +### 🎨 Modern Experience -- **Unit Toggle**: Seamlessly switch between **Metric** (km/m) and **Imperial** (mi/ft). -- **Map Themes**: One-click switching between Dark Mode, Light Mode, Topography, and Satellite layers. -- **Responsive Design**: Clean, glassmorphism-inspired UI that works on desktop and tablets. +- **Responsive UI**: "Glassmorphism" design with a collapsible sidebar and mobile-friendly drawer navigation. +- **Dynamic Maps**: Seamlessly switch between **Dark Matter**, **Light**, **Topography**, and **Satellite** basemaps. +- **Metric/Imperial**: Toggle between Metric (km/m) and Imperial (mi/ft) units on the fly. -## Getting Started +--- + +## 🚀 Getting Started ### Prerequisites -- [Node.js](https://nodejs.org/) (v18 or higher) -- **OR** -- [Docker](https://www.docker.com/) +- [Node.js](https://nodejs.org/) (v18+) **OR** [Docker](https://www.docker.com/) + +### 🐳 Running with Docker (Recommended) + +1. **Pull and Run**: + + ```bash + docker run -d -p 5173:5173 ghcr.io/d3mocide/meshrf:latest + ``` + +2. **Custom Configuration (Docker Compose)**: + You can configure the default map location and elevation source via environment variables. -### Running Locally (Node.js) + ```yaml + services: + app: + image: ghcr.io/d3mocide/meshrf:latest + ports: + - "5173:5173" + environment: + - VITE_MAP_LAT=45.5152 # Default Latitude (Portland, OR) + - VITE_MAP_LNG=-122.6784 # Default Longitude + - VITE_ELEVATION_API_URL=https://api.open-meteo.com/v1/elevation + - ALLOWED_HOSTS=my-meshrf.com # For reverse proxies + ``` + +3. Open `http://localhost:5173` in your browser. + +### 💻 Running Locally (Development) 1. Clone the repository: @@ -54,75 +79,40 @@ A modern, web-based RF simulation and link analysis tool designed for LoRa Mesh npm install ``` -3. Start the development server: - +3. Start the dev server: ```bash npm run dev ``` -4. Open `http://localhost:5173` in your browser. - -### Running with Docker (No Install) - -**Option 1: Docker CLI** - -```bash -docker run -d -p 5173:5173 ghcr.io/d3mocide/meshrf:latest -``` - -**Option 2: Docker Compose** - -Sample Compose File - -```yml -services: - app: - image: ghcr.io/d3mocide/meshrf:latest - container_name: meshrf - ports: - - "5173:5173" - environment: - #Add your hostnames if using behind a reverse proxy - - ALLOWED_HOSTS=localhost - command: npm run dev -- --host -``` - -```bash -docker-compose up -d -``` - -_Note: You can configure `ALLOWED_HOSTS` in `docker-compose.yml` if accessing via a custom domain._ - -2. Open `http://localhost:5173` in your browser. - -## Usage Guide +--- -1. **Place Nodes**: Click anywhere on the map to place your **TX** (Transmitter) node. Click again to place your **RX** (Receiver) node. -2. **Adjust Hardware**: Use the Sidebar to select your device (e.g., _Heltec V3_) and antenna height. -3. **Analyze**: - - **Green Line**: Clear Line of Sight with good signal margin. - - **Yellow Line**: Marginal connection or partial Fresnel obstruction. - - **Red Line**: Obstructed path or insufficient signal power. -4. **Check Profile**: Look at the top-right overlay to see the terrain cross-section and exact clearance values. +## 📐 Usage Guide -## Technologies Used +1. **Placement**: Click on the map to place **TX** (Point A) and **RX** (Point B). + - _Tip: Click again to move points._ +2. **Configuration**: Open the sidebar to select your specific device hardware and antenna height. +3. **Environment**: Adjust **Refraction (K)** for atmospheric conditions and **Clutter** (e.g., 10m for trees) to see real-world impact. +4. **Analysis**: + - **Green**: Good/Excellent Connection (>60% Fresnel Clearance). + - **Yellow**: Marginal (LOS exists but Fresnel is infringed). + - **Red**: Obstructed (Earth or Terrain blocking). +5. **Batch**: Use the "Import Nodes" button to upload a CSV and generate a full mesh network report. -- **Vite + React**: Fast, modern frontend framework. -- **Leaflet + React-Leaflet**: robust mapping library. -- **Turf.js**: Advanced geospatial analysis. -- **Open-Meteo API**: Free, open-source global elevation data. +--- -## Project Structure +## 🏗️ Project Structure -- `src/components`: UI blocks (Map, Sidebar, Panels). -- `src/context`: Global state management (RF settings). -- `src/utils`: Math engines for RF propagation and Elevation processing. -- `src/data`: Config files for device and antenna presets. +- `src/components`: UI components (Map, Sidebar, Charts). +- `src/context`: Global RF state and batch processing logic. +- `src/utils`: + - `rfMath.js`: Core physics engine (Geodetic calc, Fresnel, Path Loss). + - `elevation.js`: DEM data fetching and processing. +- `src/data`: Hardware definition libraries. -## License +## 📄 License MIT License. Free to use and modify. -## Disclaimer +## ⚠️ Disclaimer -This tool is provided as-is for educational and planning purposes only. This tool was created with Gemini 3.5 so results are not guaranteed to be accurate. +This tool is a simulation based on mathematical models. Real-world RF propagation is affected by complex factors (interference, buildings, weather) not fully modeled here. Always verify with field testing. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 978590f..c3c6323 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,17 +1,46 @@ -# Release Notes - v0.2-rc +# Release Notes -## MeshRF - The Branding Update +## v1.0 - Professional Edition -This release brings a polished identity to the project and important configuration fixes for deployment. +This major release transforms **meshRF** into a professional-grade RF planning tool, introducing geodetic physics, batch processing, and a completely modernized UI. + +### 🌐 Physics Engine Upgrade + +- **Geodetic Earth Model**: Implemented curved-earth calculations with configurable **K-Factor**. +- **Accurate Fresnel Analysis**: Now strictly enforces the **60% Clearance Rule** (WISP Standard) for link quality ratings (Excellent/Good/Marginal/Obstructed). +- **Clutter Awareness**: Added support for **Clutter Height** (trees/urban) in obstruction analysis. + +### ⚡ Batch Processing + +- **CSV Import**: Analyze hundreds of nodes at once by importing a simple CSV (`Name, Lat, Lon`). +- **Matrix Analysis**: Automatically computes link feasibility for every pair of nodes (N\*(N-1)/2 links). +- **Bulk Export**: Download detailed link budget reports (RSSI, Margin, Clearance) as CSV. + +### 🎨 UI Modernization + +- **Responsive Sidebar**: Collapsible, glassmorphism sidebar that works perfectly on mobile devices. +- **Floating Controls**: Smart "Tab" toggle that floats independently of the sidebar. +- **Visual Polish**: Custom dark-mode scrollbars, refined typography, and new "meshRF" branding with custom iconography. +- **Link Analysis Panel**: Now fully resizable with a draggable handle for better chart visibility. + +### 🛠️ Configuration & Deployment + +- **Environment Variables**: + - `VITE_ELEVATION_API_URL`: Configure your own elevation provider (e.g., self-hosted Open-Meteo). + - `VITE_MAP_LAT` / `VITE_MAP_LNG`: Set custom default starting coordinates. +- **Refined Docker**: Optimized Docker Compose setup for easy deployment. + +--- + +## v0.2-rc - Branding Update ### 🎨 Branding & Identity -- **New Name**: Officially renamed to **MeshRF**. -- **New Icon**: Added a sleek, cyber-aesthetic SVG icon (Gradient Hexagon). -- **UI Updates**: Updated title in Browser Tab and Sidebar. +- **New Name**: Officially renamed to **meshRF**. +- **New Icon**: Added stylized RF signal icon. +- **UI Updates**: Updated browser title and sidebar header. -### ⚙️ configuration & Troubleshooting +### ⚙️ Configuration -- **Custom Domains**: Added `ALLOWED_HOSTS` support for deploying behind reverse proxies or on custom domains (fixes "Blocked host" errors). -- **Docker Publishing**: Workflow now explicitly builds and pushes the `latest` tag to GHCR. -- **Documentation**: Updated `README.md` with correct clone URLs and Docker run commands. +- **Allowed Hosts**: Added `ALLOWED_HOSTS` support for reverse proxy deployments. +- **Docker Workflow**: Automated `latest` tag publishing. diff --git a/docker-compose.yml b/docker-compose.yml index 4e2c8e4..a5660bf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,14 @@ services: ports: - "5173:5173" environment: - #Add your hostname if using behind a reverse proxy + # Hostname for reverse proxy - ALLOWED_HOSTS=localhost + # Custom Elevation API Endpoint (Optional) + # - VITE_ELEVATION_API_URL=http://localhost:8080/v1/elevation + # Default Map Center (Portland, OR) + - VITE_MAP_LAT=45.5152 + - VITE_MAP_LNG=-122.6784 + volumes: + - .:/app + - /app/node_modules command: npm run dev -- --host diff --git a/public/icon.svg b/public/icon.svg index 1830534..fc86b96 100644 --- a/public/icon.svg +++ b/public/icon.svg @@ -1,20 +1,14 @@ - - - - - - - + + - - - - - - - - + + + + + - + diff --git a/public/meshrf-preview.png b/public/meshrf-preview.png index 251d944..9300193 100644 Binary files a/public/meshrf-preview.png and b/public/meshrf-preview.png differ diff --git a/src/components/Layout/Sidebar.jsx b/src/components/Layout/Sidebar.jsx index 38c8bab..6612390 100644 --- a/src/components/Layout/Sidebar.jsx +++ b/src/components/Layout/Sidebar.jsx @@ -1,6 +1,8 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { RADIO_PRESETS, DEVICE_PRESETS, ANTENNA_PRESETS } from '../../data/presets'; import { useRF } from '../../context/RFContext'; +import { fetchElevationPath } from '../../utils/elevation'; +import { analyzeLinkProfile, calculateLinkBudget } from '../../utils/rfMath'; const Sidebar = () => { const { @@ -16,9 +18,28 @@ const Sidebar = () => { cr, setCr, erp, cableLoss, units, setUnits, - mapStyle, setMapStyle + mapStyle, setMapStyle, + kFactor, setKFactor, + clutterHeight, setClutterHeight, + batchNodes, setBatchNodes } = useRF(); + // Responsive & Collapse Logic + const [isMobile, setIsMobile] = useState(window.innerWidth < 768); + const [isOpen, setIsOpen] = useState(window.innerWidth > 768); + + useEffect(() => { + const handleResize = () => { + const mobile = window.innerWidth < 768; + setIsMobile(mobile); + // Optional: Auto-collapse on small screens + if (mobile && isOpen) setIsOpen(false); + if (!mobile && !isOpen) setIsOpen(true); + }; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + const handleTxPowerChange = (e) => { setTxPower(Math.min(Number(e.target.value), DEVICE_PRESETS[selectedDevice].tx_power_max)); }; @@ -57,26 +78,62 @@ const Sidebar = () => { return ( -

- MeshRF + App Icon meshRF

{/* DEVICE SELECTION */} @@ -259,8 +316,179 @@ const Sidebar = () => { + {/* Environmental Settings */} +
+ + +
+
+ + setKFactor(parseFloat(e.target.value))} + style={{...inputStyle, padding: '2px 4px', fontSize: '0.85em'}} + /> +
+
+ + setClutterHeight(parseFloat(e.target.value))} + style={{...inputStyle, padding: '2px 4px', fontSize: '0.85em'}} + /> +
+
+
+ K=1.33 Standard, K=1.0 Bare Earth +
+
+ + {/* Batch Operations */} +
+ + + {/* Import */} +
+ +
Format: Name, Lat, Lon
+
+ + {/* Export Report */} + {batchNodes.length > 1 && ( + + )} + {batchNodes.length > 0 && ( +
{batchNodes.length} Nodes Loaded
+ )} +
+ {/* Map Style Selector */} -
+
+ + {/* Footer */} +
+ + + + + d3mocide/MeshRF + +
+ ); }; diff --git a/src/components/Map/LinkAnalysisPanel.jsx b/src/components/Map/LinkAnalysisPanel.jsx index 990b47b..3a30ab0 100644 --- a/src/components/Map/LinkAnalysisPanel.jsx +++ b/src/components/Map/LinkAnalysisPanel.jsx @@ -1,7 +1,7 @@ import React from 'react'; import LinkProfileChart from './LinkProfileChart'; -const LinkAnalysisPanel = ({ nodes, linkStats, budget, distance, units }) => { // added units prop +const LinkAnalysisPanel = ({ nodes, linkStats, budget, distance, units }) => { if (nodes.length !== 2) return null; // Conversions @@ -13,38 +13,135 @@ const LinkAnalysisPanel = ({ nodes, linkStats, budget, distance, units }) => { / // Colors const isObstructed = linkStats.isObstructed; const margin = budget ? budget.margin : 0; - const isGood = !isObstructed && margin > 10; - const isWarn = !isObstructed && margin > 0 && margin <= 10; - let statusColor = '#00ff41'; // Green - let statusText = 'EXCELLENT'; + // WISP Ratings + const quality = linkStats.linkQuality || 'Obstructed (-)'; - if (isObstructed) { - statusColor = '#ff0000'; - statusText = 'OBSTRUCTED'; - } else if (margin < 0) { + let statusColor = '#ff0000'; // Default Red + let statusText = quality.toUpperCase(); + + if (quality.includes('Excellent')) { + statusColor = '#00ff41'; // Green + } else if (quality.includes('Good')) { + statusColor = '#00ff41'; // Green (maybe slightly different?) + } else if (quality.includes('Marginal')) { + statusColor = '#ffbf00'; // Amber + } else if (quality.includes('Obstructed')) { + statusColor = '#ff0000'; // Red + } + + // Override if Margin is bad (even if Fresnel is clear) + if (margin < 0) { statusColor = '#ff0000'; - statusText = 'NO LINK'; - } else if (isWarn) { - statusColor = '#ffbf00'; - statusText = 'MARGINAL'; + statusText = 'NO SIGNAL'; + } else if (margin < 10 && !quality.includes('Obstructed')) { + // If margin is low but LOS is clear, warn + if (statusColor === '#00ff41') statusColor = '#ffbf00'; + if (!statusText.includes('MARGINAL')) statusText += ' (LOW SNR)'; } + // Responsive Chart Logic + const [panelSize, setPanelSize] = React.useState({ width: 300, height: 350 }); + const [dimensions, setDimensions] = React.useState({ width: 268, height: 100 }); + const panelRef = React.useRef(null); + const draggingRef = React.useRef(false); + const lastPosRef = React.useRef({ x: 0, y: 0 }); + + // Update Chart Dimensions when Panel Size changes + React.useEffect(() => { + setDimensions({ + width: Math.max(260, panelSize.width - 32), + height: Math.max(100, panelSize.height - 250) + }); + }, [panelSize]); + + // Resize Handler + const handleMouseDown = (e) => { + draggingRef.current = true; + lastPosRef.current = { x: e.clientX, y: e.clientY }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + e.preventDefault(); // Prevent selection + }; + + const handleMouseMove = (e) => { + if (!draggingRef.current) return; + + const dx = e.clientX - lastPosRef.current.x; + const dy = e.clientY - lastPosRef.current.y; + + lastPosRef.current = { x: e.clientX, y: e.clientY }; + + setPanelSize(prev => { + // Dragging Left (negative dx) should INCREASE width (since anchored right) + // Dragging Down (positive dy) should INCREASE height + + const newWidth = prev.width - dx; + const newHeight = prev.height + dy; + + return { + width: Math.max(300, newWidth), + height: Math.max(300, newHeight) + }; + }); + }; + + const handleMouseUp = () => { + draggingRef.current = false; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + return ( -
+ {/* Custom Bottom-Left Resize Handle */} +
e.currentTarget.style.backgroundColor = 'rgba(255, 255, 255, 0.15)'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'rgba(255, 255, 255, 0.05)'} + title="Resize Panel" + >
+ {/* Header */}

Link Analysis

@@ -61,7 +158,7 @@ const LinkAnalysisPanel = ({ nodes, linkStats, budget, distance, units }) => { /
{/* Stats Grid */} -
+
Distance
{distDisplay}
@@ -80,31 +177,33 @@ const LinkAnalysisPanel = ({ nodes, linkStats, budget, distance, units }) => { /
- {/* Profile Chart */} -
+ {/* Profile Chart - Flexible Height */} +
Terrain & Path Profile
{linkStats.loading ? ( -
+
Loading Elevation Data...
) : ( - +
+ +
)}
{/* Legend / Info */} -
+
{/* Left margin to avoid handle */}
LOS
-
+
Terrain
diff --git a/src/components/Map/LinkLayer.jsx b/src/components/Map/LinkLayer.jsx index 6758515..b8f37db 100644 --- a/src/components/Map/LinkLayer.jsx +++ b/src/components/Map/LinkLayer.jsx @@ -4,10 +4,8 @@ import L from 'leaflet'; import { useRF } from '../../context/RFContext'; import { calculateLinkBudget, calculateFresnelRadius, calculateFresnelPolygon, analyzeLinkProfile } from '../../utils/rfMath'; import { fetchElevationPath } from '../../utils/elevation'; -import { feature } from '@turf/turf'; import * as turf from '@turf/turf'; -// Custom Icons // Custom Icons (DivIcon for efficiency) const txIcon = L.divIcon({ @@ -26,7 +24,8 @@ const rxIcon = L.divIcon({ const LinkLayer = ({ nodes, setNodes, linkStats, setLinkStats }) => { const { - txPower, antennaGain, freq, sf, bw, cableLoss, antennaHeight + txPower, antennaGain, freq, sf, bw, cableLoss, antennaHeight, + kFactor, clutterHeight } = useRF(); useMapEvents({ @@ -56,15 +55,18 @@ const LinkLayer = ({ nodes, setNodes, linkStats, setLinkStats }) => { // Analyze const analysis = analyzeLinkProfile( profile, - freq, - antennaHeight, - antennaHeight + Number(freq), + Number(antennaHeight), + Number(antennaHeight), + Number(kFactor), + Number(clutterHeight) ); setLinkStats({ loading: false, isObstructed: analysis.isObstructed, minClearance: analysis.minClearance, + linkQuality: analysis.linkQuality, profileWithStats: analysis.profileWithStats }); } else { @@ -73,7 +75,7 @@ const LinkLayer = ({ nodes, setNodes, linkStats, setLinkStats }) => { }; fetchData(); - }, [nodes, freq, antennaHeight, setLinkStats]); + }, [nodes, freq, antennaHeight, setLinkStats, kFactor, clutterHeight]); if (nodes.length < 2) { return ( diff --git a/src/components/Map/LinkProfileChart.jsx b/src/components/Map/LinkProfileChart.jsx index 2c7be42..0c15f6a 100644 --- a/src/components/Map/LinkProfileChart.jsx +++ b/src/components/Map/LinkProfileChart.jsx @@ -10,9 +10,21 @@ const LinkProfileChart = ({ profileWithStats, width = 200, height = 100, units = const distUnit = isImperial ? 'mi' : 'km'; const heightUnit = isImperial ? 'ft' : 'm'; - // Determine scales (in native metric for drawing, converted for labels) - const minElev = Math.min(...profileWithStats.map(p => p.elevation)); - const maxElev = Math.max(...profileWithStats.map(p => p.losHeight), ...profileWithStats.map(p => p.elevation)); + let minElev = Math.min( + ...profileWithStats.map(p => p.effectiveTerrain), + ...profileWithStats.map(p => p.losHeight - p.f1Radius) + ); + let maxElev = Math.max( + ...profileWithStats.map(p => p.losHeight + p.f1Radius), + ...profileWithStats.map(p => p.effectiveTerrain) + ); + + // Add margin to prevent clipping + const range = maxElev - minElev; + const padding = range * 0.1 || 10; + minElev -= padding; + maxElev += padding; + const totalDist = profileWithStats[profileWithStats.length - 1].distance; // Label Values @@ -20,7 +32,7 @@ const LinkProfileChart = ({ profileWithStats, width = 200, height = 100, units = const maxElevLabel = (maxElev * heightFactor).toFixed(0); // Padding - const p = 5; + const p = 10; const w = width - p * 2; const h = height - p * 2; @@ -28,18 +40,26 @@ const LinkProfileChart = ({ profileWithStats, width = 200, height = 100, units = const scaleY = (e) => height - p - ((e - minElev) / (maxElev - minElev)) * h; // Generate Path Data - // 1. Terrain Polygon (filled) - let terrainPath = `M ${scaleX(0)} ${height}`; // Start bottom left + + let terrainPath = `M ${scaleX(0)} ${height}`; profileWithStats.forEach(pt => { - terrainPath += ` L ${scaleX(pt.distance)} ${scaleY(pt.elevation)}`; + const elev = pt.effectiveTerrain !== undefined ? pt.effectiveTerrain : pt.elevation; + terrainPath += ` L ${scaleX(pt.distance)} ${scaleY(elev)}`; }); - terrainPath += ` L ${scaleX(totalDist)} ${height} Z`; // Close to bottom right + terrainPath += ` L ${scaleX(totalDist)} ${height} Z`; + + let bareEarthPath = ""; + if (profileWithStats[0].earthBulge !== undefined) { + bareEarthPath = `M ${scaleX(0)} ${height}`; + profileWithStats.forEach((pt, i) => { + const ground = pt.elevation + pt.earthBulge; + const cmd = i === 0 ? 'M' : 'L'; + bareEarthPath += `${cmd} ${scaleX(pt.distance)} ${scaleY(ground)}`; + }); + } - // 2. LOS Line const losPath = `M ${scaleX(0)} ${scaleY(profileWithStats[0].losHeight)} L ${scaleX(totalDist)} ${scaleY(profileWithStats[profileWithStats.length - 1].losHeight)}`; - // 3. Fresnel Zone (Bottom) Line - // F1 Bottom = LOS - F1 Radius let f1Path = ""; profileWithStats.forEach((pt, i) => { const f1Bottom = pt.losHeight - pt.f1Radius; @@ -50,8 +70,11 @@ const LinkProfileChart = ({ profileWithStats, width = 200, height = 100, units = return (
- {/* Terrain */} - + {/* Terrain (Effective - Includes Clutter) */} + + + {/* Bare Earth Line (if Geodetic enabled) */} + {bareEarthPath && } {/* Fresnel Zone Bottom Limit */} @@ -59,10 +82,37 @@ const LinkProfileChart = ({ profileWithStats, width = 200, height = 100, units = {/* LOS Line */} - {/* Axis Labels (Simplified) */} - 0{distUnit} - {totalDistLabel}{distUnit} - {maxElevLabel}{heightUnit} + {/* Axis Labels (Improved Alignment) */} + + 0{distUnit} + + + {totalDistLabel}{distUnit} + + + {maxElevLabel}{heightUnit} +
); diff --git a/src/components/Map/MapContainer.jsx b/src/components/Map/MapContainer.jsx index 81d1fcf..867bf2a 100644 --- a/src/components/Map/MapContainer.jsx +++ b/src/components/Map/MapContainer.jsx @@ -22,14 +22,17 @@ let DefaultIcon = L.icon({ L.Marker.prototype.options.icon = DefaultIcon; const MapComponent = () => { - const position = [45.5152, -122.6784]; // Portland, OR + // Default Map Center (Portland, OR) + const defaultLat = parseFloat(import.meta.env.VITE_MAP_LAT) || 45.5152; + const defaultLng = parseFloat(import.meta.env.VITE_MAP_LNG) || -122.6784; + const position = [defaultLat, defaultLng]; // Lifted State const [nodes, setNodes] = useState([]); const [linkStats, setLinkStats] = useState({ minClearance: 0, isObstructed: false, loading: false }); // Calculate Budget at container level for Panel - const { txPower, antennaGain, freq, sf, bw, cableLoss, units, mapStyle } = useRF(); + const { txPower, antennaGain, freq, sf, bw, cableLoss, units, mapStyle, batchNodes } = useRF(); // Map Configs const MAP_STYLES = { @@ -105,6 +108,22 @@ const MapComponent = () => { units={units} /> )} + + {/* Batch Nodes Rendering */} + {batchNodes.length > 0 && batchNodes.map((node) => ( +
`, + iconSize: [8, 8], + iconAnchor: [4, 4] + })} + > + {node.name} + + ))}
); }; diff --git a/src/context/RFContext.jsx b/src/context/RFContext.jsx index 75c0ab3..bb9da69 100644 --- a/src/context/RFContext.jsx +++ b/src/context/RFContext.jsx @@ -12,6 +12,9 @@ export const RFProvider = ({ children }) => { const [selectedRadioPreset, setSelectedRadioPreset] = useState('MESHCORE_PNW'); const [selectedDevice, setSelectedDevice] = useState('HELTEC_V3'); const [selectedAntenna, setSelectedAntenna] = useState('STUBBY'); + + // Batch Processing + const [batchNodes, setBatchNodes] = useState([]); // Array of {id, name, lat, lng} // Config Values const [txPower, setTxPower] = useState(20); @@ -22,6 +25,10 @@ export const RFProvider = ({ children }) => { const [units, setUnits] = useState('metric'); // 'metric' or 'imperial' const [mapStyle, setMapStyle] = useState('dark'); // 'dark', 'light', 'topo', 'satellite' + // Environmental + const [kFactor, setKFactor] = useState(1.33); // Standard Refraction + const [clutterHeight, setClutterHeight] = useState(0); // Forest/Urban Obstruction (m) + // Radio Params const [freq, setFreq] = useState(RADIO_PRESETS.MESHCORE_PNW.freq); const [bw, setBw] = useState(RADIO_PRESETS.MESHCORE_PNW.bw); @@ -69,6 +76,7 @@ export const RFProvider = ({ children }) => { selectedRadioPreset, setSelectedRadioPreset, selectedDevice, setSelectedDevice, selectedAntenna, setSelectedAntenna, + batchNodes, setBatchNodes, txPower, setTxPower, antennaHeight, setAntennaHeight, antennaGain, setAntennaGain, @@ -79,7 +87,9 @@ export const RFProvider = ({ children }) => { cr, setCr, erp, cableLoss, units, setUnits, - mapStyle, setMapStyle + mapStyle, setMapStyle, + kFactor, setKFactor, + clutterHeight, setClutterHeight }; return ( diff --git a/src/index.css b/src/index.css index 925e6af..5b5b886 100644 --- a/src/index.css +++ b/src/index.css @@ -14,3 +14,19 @@ width: 100%; height: 100%; } + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: #555; + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: #777; +} diff --git a/src/utils/elevation.js b/src/utils/elevation.js index 2b34b19..4d4e56b 100644 --- a/src/utils/elevation.js +++ b/src/utils/elevation.js @@ -37,9 +37,9 @@ export const fetchElevationPath = async (start, end, samples = 20) => { lngs.push(lng); } - // calls Open-Meteo Elevation API - // Format: https://api.open-meteo.com/v1/elevation?latitude=52.52,54.32&longitude=13.41,10.12 - const url = `https://api.open-meteo.com/v1/elevation?latitude=${lats.join(',')}&longitude=${lngs.join(',')}`; + // calls Open-Meteo Elevation API (or local mirror) + const baseUrl = import.meta.env.VITE_ELEVATION_API_URL || 'https://api.open-meteo.com/v1/elevation'; + const url = `${baseUrl}?latitude=${lats.join(',')}&longitude=${lngs.join(',')}`; const response = await fetch(url); if (!response.ok) throw new Error('Elevation API Failed'); diff --git a/src/utils/rfMath.js b/src/utils/rfMath.js index a03bb4e..993da2c 100644 --- a/src/utils/rfMath.js +++ b/src/utils/rfMath.js @@ -133,57 +133,109 @@ export const calculateFresnelPolygon = (p1, p2, freqMHz, steps = 30) => { return [...leftSide, ...rightSide]; }; + +/** + * Calculate Earth Bulge at a specific point + * @param {number} distKm - Distance from start point (km) + * @param {number} totalDistKm - Total link distance (km) + * @param {number} kFactor - Standard Refraction Factor (default 1.33) + * @returns {number} Bulge height in meters + */ +export const calculateEarthBulge = (distKm, totalDistKm, kFactor = 1.33) => { + // Earth Radius (km) + const R = 6371; + const Re = R * kFactor; // Effective Radius + + // Distance to second point + const d1 = distKm; + const d2 = totalDistKm - distKm; + + // h = (d1 * d2) / (2 * Re) + // Result in km, convert to meters + const hKm = (d1 * d2) / (2 * Re); + return hKm * 1000; +}; + + /** - * Analyze Link Profile for Obstructions + * Analyze Link Profile for Obstructions (Geodetic + Clutter + Fresnel Standards) * @param {Array} profile - Array of {distance, elevation} points (distance in km, elevation in m) * @param {number} freqMHz - Frequency * @param {number} txHeightAGL - TX Antenna Height (m) * @param {number} rxHeightAGL - RX Antenna Height (m) - * @returns {Object} { minClearance, isObstructed, profileWithStats } + * @param {number} kFactor - Atmospheric Refraction (default 1.33) + * @param {number} clutterHeight - Uniform Clutter Height (e.g., Trees/Urban) default 0 + * @returns {Object} { minClearance, isObstructed, linkQuality, profileWithStats } */ -export const analyzeLinkProfile = (profile, freqMHz, txHeightAGL, rxHeightAGL) => { +export const analyzeLinkProfile = (profile, freqMHz, txHeightAGL, rxHeightAGL, kFactor = 1.33, clutterHeight = 0) => { if (!profile || profile.length === 0) return { isObstructed: false, minClearance: 999 }; const startPt = profile[0]; const endPt = profile[profile.length - 1]; - const totalDistKm = endPt.distance; // Assuming 0-based distance + const totalDistKm = endPt.distance; - // Absolute Heights (AMSL - Above Mean Sea Level) - const txH = startPt.elevation + txHeightAGL; + const txH = startPt.elevation + txHeightAGL; const rxH = endPt.elevation + rxHeightAGL; let minClearance = 9999; let isObstructed = false; + let worstFresnelRatio = 1.0; // 1.0 = Fully Clear. < 0.6 = Bad. const profileWithStats = profile.map(pt => { const d = pt.distance; // km - // Linear interpolation for LOS height (Flat Earth approximation for now) + // 1. Calculate Earth Bulge + const bulge = calculateEarthBulge(d, totalDistKm, kFactor); + + // 2. Effective Terrain Height (Terrain + Bulge + Clutter) + const effectiveTerrain = pt.elevation + bulge + clutterHeight; + + // 3. LOS Height at this distance const ratio = d / totalDistKm; const losHeight = txH + (rxH - txH) * ratio; - // Fresnel Radius (m) + // 4. Fresnel Radius (m) const f1 = calculateFresnelRadius(totalDistKm, freqMHz, d); - // Clearance (m) = (LOS Height - Terrain Height) - F1 Radius - const clearance = (losHeight - pt.elevation) - f1; + // 5. Clearance (m) relative to F1 bottom + // Positive = Clear of F1. Negative = Inside F1 or Obstructed. + const distFromCenter = losHeight - effectiveTerrain; + const clearance = distFromCenter - f1; + // Ratio of Clearance / F1 Radius (for quality check) + // 60% rule means distFromCenter >= 0.6 * F1 + const fRatio = f1 > 0 ? (distFromCenter / f1) : 1; + + if (fRatio < worstFresnelRatio) worstFresnelRatio = fRatio; if (clearance < minClearance) minClearance = clearance; - // Obstructed if clearance < 0 (encroaches F1) - if (clearance < 0) isObstructed = true; + // Obstructed logic + if (distFromCenter <= 0) isObstructed = true; return { ...pt, + earthBulge: bulge, + effectiveTerrain, losHeight, f1Radius: f1, - clearance + clearance, + fresnelRatio: fRatio }; }); + // Determine Link Quality String + // Excellent (>0.8), Good (>0.6), Marginal (>0), Obstructed (<=0) + + let linkQuality = "Obstructed"; + if (worstFresnelRatio >= 0.8) linkQuality = "Excellent (+++)"; + else if (worstFresnelRatio >= 0.6) linkQuality = "Good (++)"; // 60% rule + else if (worstFresnelRatio > 0) linkQuality = "Marginal (+)"; // Visual LOS, but heavy Fresnel + else linkQuality = "Obstructed (-)"; // No Visual LOS + return { minClearance: parseFloat(minClearance.toFixed(1)), isObstructed, + linkQuality, profileWithStats }; };