diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cfc45f..d76b14e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.14.0] - 2026-02-10 + +### Added + +- **Inter-Node Link Matrix**: The Multi-Site scan now runs pairwise RF link analysis between every selected site after the viewshed completes. Each pair reports: + - **Path Loss (dB)** — Bullington terrain-diffraction model using the same RF parameters as the rest of the tool. + - **Fresnel Clearance %** — ratio of clearance to first Fresnel zone at the worst obstruction point. + - **Status** — `Viable` (≥60% Fresnel), `Degraded` (0–60%), or `Blocked` (<0%). + - **Distance** — haversine distance between the two sites (metric/imperial). +- **Marginal Coverage Metric**: Each site card now shows how much *unique* area it contributes to the network — area not already covered by any other selected site. Low unique-coverage % flags redundant placements. +- **Connectivity Score**: Each site now displays how many of the other selected sites it can reach (viable + degraded links), giving an immediate indicator of network centrality. +- **Combined Coverage Total**: The results panel header now shows the total union area covered by all selected sites combined. +- **Mesh Topology Tab**: New "Topology" tab in the results panel features: + - **Mesh Connectivity Score** — percentage of all node pairs reachable (direct or via relay). + - Direct link breakdown by status (Viable / Degraded / Blocked). + - **Multi-hop relay detection** — BFS pathfinding identifies pairs that cannot link directly but remain reachable through intermediate nodes, with hop counts. + - All-pairs path table showing the shortest viable route between every combination of sites. +- **Link Lines on Map**: Colored polylines are now drawn between every site pair when results are displayed. + - Solid cyan = viable direct link. + - Dashed gold = degraded link (partial Fresnel obstruction). + - Dashed red = blocked link (terrain obstruction). +- **Tab-aware Help**: The Help overlay now shows context-specific field definitions for whichever tab is active (Sites, Links, or Topology). +- **Node names in results**: Site names from CSV imports or manual entry are now preserved through the scan and displayed in all tabs. + +### Changed + +- **Results Panel redesigned** with three tabs replacing the single flat card list: + - **Sites** — individual site metrics (elevation, total coverage, unique coverage %, link count). + - **Links** — sorted link matrix (viable links first, blocked last). + - **Topology** — mesh health overview and full path table. +- **Scan endpoint** now forwards frequency, rx height, K-factor, and clutter height into the Celery task so pairwise link analysis uses the correct RF parameters. +- **Panel width** increased to 380px to accommodate the new four-column metrics grid. + ## [1.13.0] - 2026-02-09 ### Added diff --git a/README.md b/README.md index 9a01332..9273875 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# meshRF 📡 v1.13.0 +# meshRF 📡 v1.14.0 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 physics core combining a **Python Geodetic Engine** with **High-Performance WASM Modules**. @@ -24,6 +24,10 @@ meshRF is designed for **mission-critical availability**. It operates with **zer ### 2. 📍 Advanced Site Surveying - **Multi-Site Management**: Dedicated manager for maintaining and comparing lists of candidate sites. + - **Inter-Node Link Matrix**: Automatically analyse pairwise RF link quality (path loss, Fresnel clearance, Viable/Degraded/Blocked) between every selected site after a scan. + - **Marginal Coverage**: Per-site unique coverage percentage highlights redundant placements before deployment. + - **Mesh Topology**: BFS-based connectivity score, multi-hop relay detection, and all-pairs path table — see if your proposed network forms a true connected mesh. + - **Link Visualisation**: Coloured polylines drawn on the map between every site pair (cyan = viable, gold = degraded, red = blocked). - **Coverage Analysis**: Scan a radial area around your transmitter to identify optimal reception points based on LOS, Fresnel clearance, and signal strength. - **RF Coverage Simulator**: Optimized Wasm-powered ITM propagation modeling for wide-area coverage visualization. - **Viewshed Analysis**: Desktop-grade viewshed calculations with "Shadow Mode" visualization. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c79cf0d..b6e25b3 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,40 +1,62 @@ -# Release v1.13.0: The "Coverage Analysis" Update 📡 +# Release v1.14.0: Mesh Link Intelligence -This major feature update transforms the old "Site Finder" into a professional-grade **Coverage Analysis** tool. We've replaced the rigid grid system with a flexible **Radial Scan** engine, allowing you to instantly visualize signal quality around any transmitter. +This release transforms the Multi-Site Analysis tool from a simple coverage area reporter into a full **mesh network planning suite**. After running a scan you can now immediately answer the questions that matter most: which sites can talk to each other, how much does each site uniquely contribute, and is the proposed network actually a connected mesh? -## 🌟 Key Features +## What's New -### 1. 🎯 Radial Coverage Analysis +### 1. Inter-Node Link Quality Matrix -- **Click-to-Scan**: Simply click the map to place your Transmitter (TX), then drag to set your scan radius (up to 20km). -- **Heatmap Overlay**: See a color-coded signal quality layer that shows you exactly where reception is strongest (Green) or weakest (Red). -- **Best Links**: The system automatically identifies and ranks the top reception sites based on Line-of-Sight, Fresnel Clearance, and Signal Strength. +Every site pair in your scan is now automatically analysed for RF link viability using the same Bullington terrain-diffraction model used elsewhere in the tool. The new **Links tab** shows: -### 2. 🏔️ Interactive Terrain Profiles +| Field | What it tells you | +|---|---| +| **Status** | Viable / Degraded / Blocked based on Fresnel zone clearance | +| **Path Loss** | End-to-end path loss in dB — compare against your link budget | +| **Fresnel Clearance** | % of first Fresnel zone clear at the worst obstruction point | +| **Distance** | Haversine distance between the two sites | -- **Deep Dive**: Click any "Best Link" marker to open a detailed **Terrain Profile**. -- **Visual Physics**: See the actual ground elevation, the direct Line-of-Sight path, and the Fresnel Zone clearance in a beautiful, interactive chart. +Links are sorted viable-first so the best candidates are always at the top. -### 3. 💾 Data Export +### 2. Marginal Coverage per Site -- **Take it with you**: Export your analysis results to **CSV** for spreadsheets or **KML** for 3D visualization in Google Earth. +The **Sites tab** now shows how much *unique* area each node contributes — area that no other selected site covers. This catches redundant placements before you deploy: -### 4. 🎛️ Advanced RF Controls +- **High unique %** (cyan) → critical site, removing it creates a coverage gap. +- **Low unique %** (red) → redundant placement, consider relocating. -- **Fine-Tuning**: Adjust **Refraction (K-Factor)** and **Clutter Height** directly from the map interface to model different atmospheric conditions and environments. +### 3. Mesh Connectivity Score & Topology Tab -## 🛠️ Enhancements +The new **Topology tab** gives a network-level health summary: -- **Renamed**: "Elevation Scan" is now **Coverage Analysis** to better reflect its capabilities. -- **Polished**: Scroll propagation is now blocked in panels, preventing accidental map zooms while viewing results. -- **Clarified**: Tooltips and guidance overlays have been rewritten for clarity. +- **Mesh Connectivity Score** — percentage of all node pairs that can communicate, either directly or via relay. A fully connected mesh scores 100%. +- **Multi-hop relay detection** — BFS pathfinding finds pairs that are blocked on a direct link but reachable through intermediate nodes, with the hop count shown. +- **All-pairs path table** — every combination of sites with its shortest viable path and status. -## 🚀 How to Upgrade +### 4. Link Lines on the Map -1. Pull the latest: `git pull origin main` -2. Update dependencies: `docker exec meshrf_dev npm install` -3. Restart containers: `docker compose -f docker-compose.dev.yml restart` +Coloured polylines are drawn between every site pair when results are shown, so the topology is visible directly on the terrain: + +- **Solid cyan** — viable direct link +- **Dashed gold** — degraded link (partial terrain obstruction) +- **Dashed red** — blocked (no direct LOS path) + +### 5. Combined Coverage Total + +The results panel header now shows the total **union coverage area** of all selected sites — the actual unique terrain covered by the entire proposed network, not the sum of individual footprints. + +## Improvements + +- Site names from CSV imports are now preserved through the scan and appear in all three tabs and on the map polyline tooltips. +- The Help button is now context-aware — it explains the fields for whichever tab you have open. +- The `/scan/start` API endpoint now forwards RF parameters (frequency, K-factor, clutter height) into the Celery task so link analysis uses your configured settings. + +## How to Upgrade + +```bash +git pull origin main +docker compose -f docker-compose.dev.yml up -d --build +``` --- -_See the unseen. Happy scanning!_ +*Plan smarter. Know your mesh before you deploy.* diff --git a/package.json b/package.json index e116cdf..981ee54 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "meshrf", "private": true, - "version": "1.13.0", + "version": "1.14.0", "type": "module", "scripts": { "dev": "vite", diff --git a/rf-engine/server.py b/rf-engine/server.py index bdb4d5d..116a73f 100644 --- a/rf-engine/server.py +++ b/rf-engine/server.py @@ -200,8 +200,12 @@ def start_scan_endpoint(req: dict): task = calculate_batch_viewshed.delay({ "nodes": nodes, "options": { - "radius": 5000, - "optimize_n": optimize_n + "radius": req.get("radius", 5000), + "optimize_n": optimize_n, + "frequency_mhz": req.get("frequency_mhz", 915.0), + "rx_height": req.get("rx_height", 2.0), + "k_factor": req.get("k_factor", 1.333), + "clutter_height": req.get("clutter_height", 0.0) } }) diff --git a/rf-engine/tasks/viewshed.py b/rf-engine/tasks/viewshed.py index 889426b..eb64a9d 100644 --- a/rf-engine/tasks/viewshed.py +++ b/rf-engine/tasks/viewshed.py @@ -9,6 +9,7 @@ from core.algorithms import calculate_viewshed from tile_manager import TileManager from models import NodeConfig +import rf_physics logger = get_task_logger(__name__) @@ -92,6 +93,8 @@ def lon_to_x(lon): node_res = { "lat": lat, "lon": lon, + "name": node_data.get('name', f'Site {i + 1}'), + "height": height, "elevation": round(float(source_elev), 1), "coverage_area_km2": round((coverage_count * (res_m * res_m)) / 1_000_000.0, 2), "grid": grid, @@ -168,7 +171,85 @@ def lon_to_x(lon): # No more gain to be had (or empty) break - # 3. Blit to Master Grid and generate Composite + # 3. Compute marginal coverage for each selected node (in selection order) + covered_so_far = set() + for res in selected_results: + g = res['grid'] + lats_g = res['grid_lats'] + lons_g = res['grid_lons'] + visible_indices = np.argwhere(g > 0) + node_pixels = set() + for r, c in visible_indices: + y = lat_to_y(lats_g[r]) + x = lon_to_x(lons_g[c]) + if 0 <= y < rows and 0 <= x < cols: + node_pixels.add((y, x)) + marginal_pixels = len(node_pixels - covered_so_far) + covered_so_far.update(node_pixels) + res['marginal_coverage_km2'] = round((marginal_pixels * (res_m * res_m)) / 1_000_000.0, 2) + + total_unique_km2 = round((len(covered_so_far) * (res_m * res_m)) / 1_000_000.0, 2) + for res in selected_results: + total_cov = res['coverage_area_km2'] + res['unique_coverage_pct'] = round( + (res['marginal_coverage_km2'] / total_cov * 100) if total_cov > 0 else 0.0, 1 + ) + + # 3a. Compute pairwise inter-node link quality + self.update_state(state='PROGRESS', meta={'progress': 55, 'message': 'Analyzing inter-node links...'}) + inter_node_links = [] + n_selected = len(selected_results) + for i in range(n_selected): + for j in range(i + 1, n_selected): + node_a = selected_results[i] + node_b = selected_results[j] + try: + dist_m = rf_physics.haversine_distance( + node_a['lat'], node_a['lon'], + node_b['lat'], node_b['lon'] + ) + elevs = tile_manager.get_elevation_profile( + node_a['lat'], node_a['lon'], + node_b['lat'], node_b['lon'], + samples=50 + ) + h_a = node_a.get('height', 10.0) + h_b = node_b.get('height', 10.0) + link_result = rf_physics.analyze_link( + elevs, dist_m, freq, h_a, h_b, + k_factor=options.get('k_factor', 1.333), + clutter_height=options.get('clutter_height', 0.0) + ) + path_loss_db = rf_physics.calculate_path_loss( + dist_m, elevs, freq, h_a, h_b, + model='bullington', + k_factor=options.get('k_factor', 1.333), + clutter_height=options.get('clutter_height', 0.0) + ) + inter_node_links.append({ + "node_a_idx": i, + "node_b_idx": j, + "node_a_name": node_a.get('name', f'Site {i + 1}'), + "node_b_name": node_b.get('name', f'Site {j + 1}'), + "dist_km": round(dist_m / 1000, 2), + "status": link_result['status'], + "path_loss_db": round(float(path_loss_db), 1), + "min_clearance_ratio": round(float(link_result['min_clearance_ratio']), 2) + }) + except Exception as e: + logger.error(f"Link analysis failed for nodes {i}-{j}: {e}") + inter_node_links.append({ + "node_a_idx": i, + "node_b_idx": j, + "node_a_name": selected_results[i].get('name', f'Site {i + 1}'), + "node_b_name": selected_results[j].get('name', f'Site {j + 1}'), + "dist_km": 0, + "status": "unknown", + "path_loss_db": 0, + "min_clearance_ratio": 0 + }) + + # 4. Blit to Master Grid and generate Composite for res in selected_results: g = res['grid'] lats = res['grid_lats'] @@ -190,17 +271,31 @@ def lon_to_x(lon): # 5. Build Final Output final_results = [] - for res in selected_results: + for idx, res in enumerate(selected_results): final_results.append({ "lat": res["lat"], "lon": res["lon"], + "name": res.get("name", f"Site {idx + 1}"), "elevation": res["elevation"], - "coverage_area_km2": res["coverage_area_km2"] + "coverage_area_km2": res["coverage_area_km2"], + "marginal_coverage_km2": res.get("marginal_coverage_km2", res["coverage_area_km2"]), + "unique_coverage_pct": res.get("unique_coverage_pct", 100.0) }) + # Compute connectivity score per node (# of viable/degraded links) + connectivity = [0] * len(final_results) + for link in inter_node_links: + if link["status"] in ("viable", "degraded"): + connectivity[link["node_a_idx"]] += 1 + connectivity[link["node_b_idx"]] += 1 + for idx, res in enumerate(final_results): + res["connectivity_score"] = connectivity[idx] + return { - "status": "completed", + "status": "completed", "results": final_results, + "inter_node_links": inter_node_links, + "total_unique_coverage_km2": total_unique_km2, "composite": { "image": f"data:image/png;base64,{img_str}", "bounds": [min_lat, min_lon, max_lat, max_lon] diff --git a/src/components/Map/MapContainer.jsx b/src/components/Map/MapContainer.jsx index 42b58ae..26eb248 100644 --- a/src/components/Map/MapContainer.jsx +++ b/src/components/Map/MapContainer.jsx @@ -5,6 +5,7 @@ import { ImageOverlay, Marker, Popup, + Polyline, Rectangle, ZoomControl, } from "react-leaflet"; @@ -346,7 +347,7 @@ const MapComponent = () => { // Simulation Store integration - const { nodes: simNodes, results: simResults, compositeOverlay } = useSimulationStore(); + const { nodes: simNodes, results: simResults, compositeOverlay, interNodeLinks, totalUniqueCoverageKm2 } = useSimulationStore(); // Automatically show results panel when scan finishes useEffect(() => { @@ -835,6 +836,23 @@ const MapComponent = () => { ))} + {/* Inter-node link quality polylines */} + {showAnalysisResults && simResults && interNodeLinks && interNodeLinks.map((link, i) => { + const nodeA = simResults[link.node_a_idx]; + const nodeB = simResults[link.node_b_idx]; + if (!nodeA || !nodeB) return null; + const colorMap = { viable: '#00f2ff', degraded: '#ffd700', blocked: '#ff4444', unknown: '#888' }; + const color = colorMap[link.status] || '#888'; + const dashArray = link.status === 'blocked' ? '6 6' : link.status === 'degraded' ? '10 4' : null; + return ( + + ); + })} + {/* Batch Nodes Panel - Must be inside MapContainer to use useMap hook */} {showBatchPanel && batchNodes.length > 0 && ( { {/* Site Analysis Results Panel moved outside to prevent click-through */} {showAnalysisResults && simResults && simResults.length > 0 && ( - { if (map) { @@ -1071,7 +1091,7 @@ const MapComponent = () => { }} onRunNew={() => { setShowAnalysisResults(false); - setToolMode('optimize'); + setToolMode('optimize'); setSiteAnalysisMode('manual'); }} /> diff --git a/src/components/Map/UI/SiteAnalysisResultsPanel.jsx b/src/components/Map/UI/SiteAnalysisResultsPanel.jsx index 838ea5c..3358c51 100644 --- a/src/components/Map/UI/SiteAnalysisResultsPanel.jsx +++ b/src/components/Map/UI/SiteAnalysisResultsPanel.jsx @@ -1,8 +1,380 @@ import React, { useState, useEffect } from 'react'; -const SiteAnalysisResultsPanel = ({ results, onClose, onCenter, onClear, onRunNew, units }) => { +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const STATUS_COLORS = { + viable: '#00f2ff', + degraded: '#ffd700', + blocked: '#ff4444', + unknown: '#888', +}; + +const STATUS_LABELS = { + viable: 'Viable', + degraded: 'Degraded', + blocked: 'Blocked', + unknown: 'Unknown', +}; + +function statusBadge(status) { + const color = STATUS_COLORS[status] || STATUS_COLORS.unknown; + return ( + + {STATUS_LABELS[status] || status} + + ); +} + +/** Build adjacency list and find all viable multi-hop paths between every pair */ +function findMeshPaths(results, interNodeLinks) { + if (!results || !interNodeLinks) return []; + const n = results.length; + // adjacency: node_idx -> list of {neighbor, status} + const adj = Array.from({ length: n }, () => []); + for (const link of interNodeLinks) { + if (link.status === 'viable' || link.status === 'degraded') { + adj[link.node_a_idx].push({ neighbor: link.node_b_idx, status: link.status }); + adj[link.node_b_idx].push({ neighbor: link.node_a_idx, status: link.status }); + } + } + + const paths = []; + // BFS shortest path for all pairs + for (let src = 0; src < n; src++) { + for (let dst = src + 1; dst < n; dst++) { + // BFS + const visited = new Array(n).fill(false); + const queue = [{ node: src, path: [src], worstStatus: 'viable' }]; + visited[src] = true; + let found = null; + while (queue.length > 0 && !found) { + const { node, path, worstStatus } = queue.shift(); + for (const { neighbor, status } of adj[node]) { + if (!visited[neighbor]) { + const newWorst = (worstStatus === 'degraded' || status === 'degraded') ? 'degraded' : 'viable'; + const newPath = [...path, neighbor]; + if (neighbor === dst) { + found = { path: newPath, status: newWorst }; + } else { + visited[neighbor] = true; + queue.push({ node: neighbor, path: newPath, worstStatus: newWorst }); + } + } + } + } + if (found) { + paths.push({ + src, + dst, + path: found.path, + status: found.status, + hops: found.path.length - 1 + }); + } else { + paths.push({ src, dst, path: [src, dst], status: 'blocked', hops: 1 }); + } + } + } + return paths; +} + +// ─── Tabs ───────────────────────────────────────────────────────────────────── + +function SitesTab({ results, units, onCenter }) { + return ( +
+ {results.map((res, index) => { + const connScore = res.connectivity_score ?? 0; + const connMax = results.length - 1; + const connColor = connMax === 0 ? '#888' + : connScore === connMax ? '#00f2ff' + : connScore > 0 ? '#ffd700' + : '#ff4444'; + + const uniquePct = res.unique_coverage_pct ?? 100; + const uniqueColor = uniquePct >= 70 ? '#00f2ff' + : uniquePct >= 30 ? '#ffd700' + : '#ff4444'; + + return ( +
onCenter(res)} + onMouseOver={e => { + e.currentTarget.style.background = 'rgba(0,242,255,0.08)'; + e.currentTarget.style.borderColor = 'rgba(0,242,255,0.2)'; + }} + onMouseOut={e => { + e.currentTarget.style.background = 'rgba(255,255,255,0.03)'; + e.currentTarget.style.borderColor = 'rgba(255,255,255,0.05)'; + }} + > +
+ + {res.name || `Site ${index + 1}`} + + + {res.lat.toFixed(4)}, {res.lon.toFixed(4)} + +
+ +
+
+
Elevation
+
+ {units === 'imperial' + ? `${(res.elevation * 3.28084).toFixed(1)} ft` + : `${res.elevation} m`} +
+
+
+
Coverage Area
+
+ {units === 'imperial' + ? `${(res.coverage_area_km2 * 0.386102).toFixed(2)} mi²` + : `${res.coverage_area_km2} km²`} +
+
+
+
Unique Coverage
+
+ {uniquePct.toFixed(0)}% + + ({units === 'imperial' + ? `${((res.marginal_coverage_km2 || 0) * 0.386102).toFixed(2)} mi²` + : `${(res.marginal_coverage_km2 || 0).toFixed(2)} km²`}) + +
+
+
+
Links
+
+ {connScore}/{connMax} nodes +
+
+
+
+ ); + })} +
+ ); +} + +function LinksTab({ results, interNodeLinks, units }) { + if (!interNodeLinks || interNodeLinks.length === 0) { + return ( +
+ {results.length < 2 + ? 'Add at least 2 sites to see link analysis.' + : 'No link data available.'} +
+ ); + } + + const sortedLinks = [...interNodeLinks].sort((a, b) => { + const order = { viable: 0, degraded: 1, blocked: 2, unknown: 3 }; + return (order[a.status] ?? 3) - (order[b.status] ?? 3); + }); + + return ( +
+
+ {interNodeLinks.length} link{interNodeLinks.length !== 1 ? 's' : ''} between {results.length} sites +
+ {sortedLinks.map((link, i) => { + const color = STATUS_COLORS[link.status] || '#888'; + return ( +
+
+ + {link.node_a_name} → {link.node_b_name} + + {statusBadge(link.status)} +
+
+
+
Distance
+
+ {units === 'imperial' + ? `${(link.dist_km * 0.621371).toFixed(2)} mi` + : `${link.dist_km.toFixed(2)} km`} +
+
+
+
Path Loss
+
{link.path_loss_db} dB
+
+
+
Fresnel
+
= 0.6 ? '#00f2ff' : link.min_clearance_ratio >= 0 ? '#ffd700' : '#ff4444' }}> + {link.min_clearance_ratio > 50 ? 'Clear' : `${(link.min_clearance_ratio * 100).toFixed(0)}%`} +
+
+
+
+ ); + })} +
+ ); +} + +function TopologyTab({ results, interNodeLinks }) { + const paths = findMeshPaths(results, interNodeLinks); + + const viableDirect = (interNodeLinks || []).filter(l => l.status === 'viable').length; + const degradedDirect = (interNodeLinks || []).filter(l => l.status === 'degraded').length; + const blockedDirect = (interNodeLinks || []).filter(l => l.status === 'blocked').length; + + const multihopViable = paths.filter(p => p.status !== 'blocked' && p.hops > 1).length; + const totalPairs = paths.length; + const reachable = paths.filter(p => p.status !== 'blocked').length; + + const meshScore = totalPairs > 0 ? Math.round((reachable / totalPairs) * 100) : 0; + const meshScoreColor = meshScore >= 80 ? '#00f2ff' : meshScore >= 50 ? '#ffd700' : '#ff4444'; + + return ( +
+ {/* Mesh health summary */} +
+
+ Mesh Connectivity Score +
+
+ {meshScore}% +
+
+ {reachable} of {totalPairs} node pair{totalPairs !== 1 ? 's' : ''} reachable (direct or multi-hop) +
+
+ + {/* Direct link summary */} +
+ {[ + { label: 'Viable', count: viableDirect, color: '#00f2ff' }, + { label: 'Degraded', count: degradedDirect, color: '#ffd700' }, + { label: 'Blocked', count: blockedDirect, color: '#ff4444' } + ].map(({ label, count, color }) => ( +
+
{count}
+
{label}
+
+ ))} +
+ + {multihopViable > 0 && ( +
+ {multihopViable} blocked pair{multihopViable !== 1 ? 's' : ''} reachable via multi-hop relay +
+ )} + + {/* Path table */} + {paths.length > 0 && ( + <> +
+ All Paths +
+ {paths.map((p, i) => { + const color = STATUS_COLORS[p.status] || '#888'; + const pathStr = p.path.map(idx => results[idx]?.name || `Site ${idx + 1}`).join(' → '); + return ( +
+ + {pathStr} + +
+ {p.hops > 1 && ( + {p.hops} hops + )} + {statusBadge(p.status)} +
+
+ ); + })} + + )} +
+ ); +} + +// ─── Main Panel ─────────────────────────────────────────────────────────────── + +const TABS = ['Sites', 'Links', 'Topology']; + +const SiteAnalysisResultsPanel = ({ + results, + interNodeLinks, + totalUniqueCoverageKm2, + onClose, + onCenter, + onClear, + onRunNew, + units +}) => { const [isMinimized, setIsMinimized] = useState(false); const [showHelp, setShowHelp] = useState(false); + const [activeTab, setActiveTab] = useState('Sites'); const [isMobile, setIsMobile] = useState(window.innerWidth < 768); useEffect(() => { @@ -17,8 +389,8 @@ const SiteAnalysisResultsPanel = ({ results, onClose, onCenter, onClear, onRunNe bottom: isMobile ? '0' : 'auto', right: isMobile ? '0' : '25px', left: isMobile ? '0' : 'auto', - width: isMobile ? '100%' : '360px', - maxHeight: isMinimized ? '60px' : (isMobile ? '85dvh' : '650px'), + width: isMobile ? '100%' : '380px', + maxHeight: isMinimized ? '60px' : (isMobile ? '85dvh' : '680px'), background: 'rgba(10, 10, 15, 0.98)', backdropFilter: 'blur(15px)', border: isMobile ? 'none' : '1px solid #00f2ff33', @@ -35,204 +407,195 @@ const SiteAnalysisResultsPanel = ({ results, onClose, onCenter, onClear, onRunNe overflow: 'hidden' }; + const helpItems = { + Sites: [ + { term: 'Elevation', def: 'Ground elevation at this location. Higher sites generally improve LOS coverage.' }, + { term: 'Coverage Area', def: 'Total terrain area visible from this site within the scan radius.' }, + { term: 'Unique Coverage', def: 'Percentage of this node\'s coverage area that is not covered by any other selected site. Low % = redundant placement.' }, + { term: 'Links', def: 'Number of other selected sites this node has a viable or degraded RF link to.' } + ], + Links: [ + { term: 'Viable', def: 'Fresnel zone ≥60% clear. Full link budget margin expected.' }, + { term: 'Degraded', def: 'Fresnel zone 0–60% clear. Link may work but with reduced margin.' }, + { term: 'Blocked', def: 'Terrain obstructs the direct LOS path. Link unlikely without relay.' }, + { term: 'Path Loss', def: 'Estimated Bullington diffraction + free-space path loss (dB). Compare to your link budget.' }, + { term: 'Fresnel', def: 'Fresnel zone clearance ratio at the most obstructed point. ≥60% is the target.' } + ], + Topology: [ + { term: 'Mesh Score', def: 'Percentage of all node pairs that can communicate (directly or via relay). 100% = fully connected mesh.' }, + { term: 'Multi-hop relay', def: 'A path with ≥2 hops means nodes that cannot reach each other directly can still pass traffic through an intermediate node.' } + ] + }; + return (
e.stopPropagation()}> {/* Header */} -
setIsMinimized(!isMinimized)} >
-

+

Site Analysis Results

-
{results.length} Locations Processed
+
+ {results.length} site{results.length !== 1 ? 's' : ''} + {totalUniqueCoverageKm2 != null && ( + · {units === 'imperial' + ? `${(totalUniqueCoverageKm2 * 0.386102).toFixed(2)} mi²` + : `${totalUniqueCoverageKm2.toFixed(2)} km²`} combined + + )} +
-
{ e.stopPropagation(); setShowHelp(!showHelp); }} - style={{ - cursor: 'pointer', - color: '#00f2ff', - fontSize: '14px', + style={{ + cursor: 'pointer', + color: '#00f2ff', + fontSize: '14px', padding: '4px 8px', - background: showHelp ? 'rgba(0, 242, 255, 0.15)' : 'rgba(0, 242, 255, 0.05)', + background: showHelp ? 'rgba(0,242,255,0.15)' : 'rgba(0,242,255,0.05)', borderRadius: '4px', - border: '1px solid rgba(0, 242, 255, 0.2)', + border: '1px solid rgba(0,242,255,0.2)', display: 'flex', alignItems: 'center', gap: '6px' }} > - - - + Help
{isMinimized ? '▲' : '▼'}
- + {/* Help Overlay */} {showHelp && (
-
- - - - - - Results Analysis Guide +
+ {activeTab} — Field Guide
-
- This panel displays the RF performance metrics for each site identified during the analysis. +
+ {(helpItems[activeTab] || []).map(({ term, def }) => ( +
+
{term}
+
{def}
+
+ ))}
-
    -
  • Elevation: Height of the ground surface at this location. Higher ground generally yields better RF line-of-sight.
  • -
  • Coverage Area: Estimated total land area where this transmitter provides reliable signal strength.
  • -
  • Center Map: Click any site card to fly the map to that location for detailed inspection.
  • -
- - +
)} - {/* Content Area */} + {/* Content */} {!isMinimized && ( <> -
- {results.map((res, index) => ( -
+ {TABS.map(tab => ( +
+ {tab} + ))}
+ {/* Tab content */} +
+ {activeTab === 'Sites' && ( + + )} + {activeTab === 'Links' && ( + + )} + {activeTab === 'Topology' && ( + + )} +
+ {/* Actions */} -
- - diff --git a/src/store/useSimulationStore.js b/src/store/useSimulationStore.js index b18e029..ff5fc45 100644 --- a/src/store/useSimulationStore.js +++ b/src/store/useSimulationStore.js @@ -5,6 +5,8 @@ const useSimulationStore = create((set, get) => ({ nodes: [], // List of candidate nodes: { id, lat, lon, height, name } results: null, // Results from batch scan compositeOverlay: null, // { image, bounds } for union of visibility + interNodeLinks: null, // Pairwise link quality between selected nodes + totalUniqueCoverageKm2: null, // Total unique coverage area (km²) of selected nodes union isScanning: false, scanProgress: 0, taskId: null, @@ -34,6 +36,8 @@ const useSimulationStore = create((set, get) => ({ nodes: [], results: null, compositeOverlay: null, + interNodeLinks: null, + totalUniqueCoverageKm2: null, isScanning: false, scanProgress: 0, taskId: null @@ -43,7 +47,7 @@ const useSimulationStore = create((set, get) => ({ const { nodes } = get(); if (nodes.length === 0) return; - set({ isScanning: true, scanProgress: 0, results: null, compositeOverlay: null }); + set({ isScanning: true, scanProgress: 0, results: null, compositeOverlay: null, interNodeLinks: null, totalUniqueCoverageKm2: null }); try { const API_TARGET = import.meta.env.VITE_API_URL || 'http://localhost:5001'; @@ -87,11 +91,15 @@ const useSimulationStore = create((set, get) => ({ } else if (payload.event === 'complete') { const actualResults = payload.data?.results || []; const composite = payload.data?.composite || null; - set({ - isScanning: false, - scanProgress: 100, + const interNodeLinks = payload.data?.inter_node_links || null; + const totalUniqueCoverageKm2 = payload.data?.total_unique_coverage_km2 ?? null; + set({ + isScanning: false, + scanProgress: 100, results: actualResults, - compositeOverlay: composite + compositeOverlay: composite, + interNodeLinks, + totalUniqueCoverageKm2 }); eventSource.close(); } else if (payload.event === 'error') {