diff --git a/REFACTORING_REPORT.md b/REFACTORING_REPORT.md index f6b380d..a256677 100644 --- a/REFACTORING_REPORT.md +++ b/REFACTORING_REPORT.md @@ -28,14 +28,14 @@ MeshRF is a full-stack RF propagation and link analysis application for LoRa mes |------|------|-------|----------|--------| | 1 | `src/components/Map/MapContainer.jsx` | 1173 | CRITICAL | **REFACTORED** | | 2 | `src/components/Layout/Sidebar.jsx` | 829 | CRITICAL | **REFACTORED** | -| 3 | `src/components/Map/LinkAnalysisPanel.jsx` | 643 | HIGH | Pending | -| 4 | `src/components/Map/UI/SiteAnalysisResultsPanel.jsx` | 609 | HIGH | Pending | +| 3 | `src/components/Map/LinkAnalysisPanel.jsx` | 643 | HIGH | **REFACTORED** | +| 4 | `src/components/Map/UI/SiteAnalysisResultsPanel.jsx` | 609 | HIGH | **REFACTORED** | | 5 | `src/components/Map/OptimizationLayer.jsx` | 517 | HIGH | Pending | | 6 | `rf-engine/server.py` | 475 | HIGH | **REFACTORED** | | 7 | `src/components/Map/UI/NodeManager.jsx` | 440 | MEDIUM | Pending | | 8 | `src/components/Map/OptimizationResultsPanel.jsx` | 435 | MEDIUM | Pending | | 9 | `src/components/Map/LinkLayer.jsx` | 429 | MEDIUM | Pending | -| 10 | `rf-engine/tasks/viewshed.py` | 398 | MEDIUM | Pending | +| 10 | `rf-engine/tasks/viewshed.py` | 398 | MEDIUM | **REFACTORED** | | 11 | `src/utils/rfMath.js` | 366 | LOW | Pending | | 12 | `src/components/Map/BatchNodesPanel.jsx` | 354 | MEDIUM | Pending | | 13 | `src/hooks/useViewshedTool.js` | 343 | MEDIUM | Pending | @@ -89,54 +89,27 @@ MeshRF is a full-stack RF propagation and link analysis application for LoRa mes #### 3. `src/components/Map/LinkAnalysisPanel.jsx` — 643 lines -**What it does**: Panel displaying point-to-point RF link analysis results — link budget, signal quality, Fresnel clearance, diffraction loss, model comparison, and drag/resize behaviour. - -**Logical sections**: -1. Status color/text logic (lines 36–72) -2. Responsive sizing logic (lines 74–91) -3. Panel drag/resize handlers (lines 93–250) -4. Help modals (lines 260–450) -5. JSX render (lines 450–643) - -**Suggested split**: - -``` -src/components/Map/ -├── LinkAnalysisPanel.jsx (~200 lines) — composition -├── UI/ -│ ├── LinkStatusIndicator.jsx (~80 lines) -│ ├── LinkBudgetDisplay.jsx (~100 lines) -│ ├── FresnelZoneVisualization.jsx (~80 lines) -│ └── ModelComparisonTable.jsx (~120 lines) -└── hooks/ - ├── useDraggablePanel.js (~100 lines) - └── useResponsiveSize.js (~40 lines) -``` +**Status**: Refactored (Phase 3) +- **Extracted Components**: + - `UI/LinkStatusIndicator.jsx`: Status header. + - `UI/LinkBudgetDisplay.jsx`: Stats grid. + - `UI/ModelComparisonTable.jsx`: Help/Info overlay. +- **Extracted Hook**: + - `hooks/useDraggablePanel.js`: Encapsulates drag and resize logic. +- **Result**: `LinkAnalysisPanel.jsx` is now a clean composition (~200 lines). --- #### 4. `src/components/Map/UI/SiteAnalysisResultsPanel.jsx` — 609 lines -**What it does**: Displays inter-node link matrix, mesh topology graph, and coverage redundancy analysis. Contains BFS path-finding algorithm inline. - -**Logical sections**: -1. Helper functions and status colors (lines 1–75) -2. BFS mesh path-finding algorithm (lines 38–110) -3. Component state and hooks (lines 120–200) -4. Link matrix table (lines 240–450) -5. Topology graph visualization (lines 460–609) - -**Suggested split**: - -``` -src/components/Map/UI/ -├── SiteAnalysisResultsPanel.jsx (~200 lines) — composition -├── LinkMatrix.jsx (~150 lines) -├── TopologyGraph.jsx (~150 lines) -└── StatusBadge.jsx (~40 lines) -src/utils/ -└── meshTopology.js (~100 lines) — BFS, connectivity algorithms -``` +**Status**: Refactored (Phase 3) +- **Extracted Components**: + - `UI/SiteAnalysis/SitesTab.jsx`: Site list and details. + - `UI/SiteAnalysis/LinksTab.jsx`: Link list and metrics. + - `UI/SiteAnalysis/TopologyTab.jsx`: Mesh topology and path analysis. +- **Extracted Utils**: + - `utils/meshTopology.js`: BFS and connectivity algorithms. +- **Result**: `SiteAnalysisResultsPanel.jsx` is now a clean orchestrator (~150 lines). --- @@ -233,16 +206,10 @@ src/utils/ #### 10. `rf-engine/tasks/viewshed.py` — 398 lines -**What it does**: Celery task for batch viewshed computation — multi-node processing, site ranking, PNG image generation, and Redis storage. - -**Suggested split**: - -``` -rf-engine/ -├── tasks/viewshed.py (~120 lines) — Celery task definition only -├── processors/viewshed_proc.py (~150 lines) — computation and ranking logic -└── utils/image_utils.py (~80 lines) — PNG encoding helpers -``` +**Status**: Refactored (Phase 3) +- **Extracted Logic**: + - `rf-engine/core/viewshed_proc.py`: Contains the heavy calculation, grid manipulation, and image generation logic. +- **Result**: `tasks/viewshed.py` is now a thin Celery task wrapper (~40 lines). --- @@ -321,14 +288,13 @@ src/hooks/ --- -### Phase 3 — Analysis Components +### Phase 3 — Analysis Components (COMPLETED) -5. **LinkAnalysisPanel.jsx** (643 → ~200 lines): Extract status, budget, Fresnel, and model comparison sub-components + `useDraggablePanel` hook. -6. **SiteAnalysisResultsPanel.jsx** (609 → ~200 lines): Extract `LinkMatrix`, `TopologyGraph`, and move BFS algorithm to `meshTopology.js`. -7. **viewshed.py** (398 → ~120 lines): Separate Celery task definition from processing logic and image utilities. +5. **LinkAnalysisPanel.jsx** (643 → ~200 lines): Extracted `LinkStatusIndicator`, `LinkBudgetDisplay`, `ModelComparisonTable` and `useDraggablePanel` hook. +6. **SiteAnalysisResultsPanel.jsx** (609 → ~200 lines): Extracted `SitesTab`, `LinksTab`, `TopologyTab` and moved topology logic to `meshTopology.js`. +7. **viewshed.py** (398 → ~40 lines): Moved calculation logic to `rf-engine/core/viewshed_proc.py`. -**Expected effort**: 2–3 days -**Risk**: Medium — analysis panels have complex prop drilling. +**Status**: Completed. --- diff --git a/rf-engine/core/viewshed_proc.py b/rf-engine/core/viewshed_proc.py new file mode 100644 index 0000000..9470e67 --- /dev/null +++ b/rf-engine/core/viewshed_proc.py @@ -0,0 +1,324 @@ +import numpy as np +import base64 +from io import BytesIO +from PIL import Image +from core.algorithms import calculate_viewshed +import rf_physics +import logging + +logger = logging.getLogger(__name__) + +def process_batch_viewshed(nodes_data, options, tile_manager, update_state_callback=None): + """ + Core processing logic for batch viewshed calculation. + Separated from Celery task for testability and clean architecture. + """ + logger.info(f"Processing batch viewshed for {len(nodes_data)} nodes") + + radius = float(options.get('radius', 5000)) + optimize_n = options.get('optimize_n') + rx_height = float(options.get('rx_height', 2.0)) + freq = float(options.get('frequency_mhz', 915.0)) + + # 1. Determine Bounding Box for Composite + if not nodes_data: + return {"status": "completed", "results": []} + + # Calculate center latitude for projection scaling + lats = [float(n['lat']) for n in nodes_data] + lons = [float(n['lon']) for n in nodes_data] + mean_lat = sum(lats) / len(lats) + + # Degrees per meter + lat_deg_per_m = 1.0 / 111320.0 + lon_deg_per_m = 1.0 / (111320.0 * max(0.001, np.cos(np.radians(mean_lat)))) + + # Buffer: radius + 1km safety margin + buffer_m = radius + 1000 + buffer_lat = buffer_m * lat_deg_per_m + buffer_lon = buffer_m * lon_deg_per_m + + min_lat = min(lats) - buffer_lat + max_lat = max(lats) + buffer_lat + min_lon = min(lons) - buffer_lon + max_lon = max(lons) + buffer_lon + + # Define Global Master Grid + target_res_m = 100.0 + + rows = int((max_lat - min_lat) / (target_res_m * lat_deg_per_m)) + cols = int((max_lon - min_lon) / (target_res_m * lon_deg_per_m)) + + MAX_DIM = 4096 + + if rows > MAX_DIM or cols > MAX_DIM: + scale_factor = max(rows / MAX_DIM, cols / MAX_DIM) + res_m = target_res_m * scale_factor + + rows = int((max_lat - min_lat) / (res_m * lat_deg_per_m)) + cols = int((max_lon - min_lon) / (res_m * lon_deg_per_m)) + + logger.warning(f"Viewshed grid too large. Scaling resolution from {target_res_m}m to {res_m:.1f}m. Grid: {rows}x{cols}") + else: + res_m = target_res_m + + master_grid = np.zeros((rows, cols), dtype=np.uint8) + + # Pre-calculate individual viewsheds + all_node_results = [] + total = len(nodes_data) + + for i, node_data in enumerate(nodes_data): + try: + lat = float(node_data.get('lat')) + lon = float(node_data.get('lon')) + height = float(node_data.get('height', 10)) + + grid, grid_lats, grid_lons = calculate_viewshed( + tile_manager, lat, lon, height, radius, + rx_h=rx_height, freq_mhz=freq, resolution_m=res_m + ) + + coverage_count = int(np.sum(grid)) + source_elev = tile_manager.get_elevation(lat, 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, + "grid_lats": grid_lats, + "grid_lons": grid_lons + } + all_node_results.append(node_res) + + if update_state_callback: + progress = int((i + 1) / total * 50) + update_state_callback('PROGRESS', {'progress': progress, 'message': f'Analyzed candidates {i+1}/{total}'}) + + except Exception as e: + logger.error(f"Error processing node {i}: {e}") + + # 2. Greedy Optimization (Marginal Gain) + selected_results = all_node_results + if optimize_n and 0 < optimize_n < len(all_node_results): + selected_results = [] + covered_points = set() + + # Pre-compute pixel sets for all candidates + candidate_sets = [] + for res in all_node_results: + pixels = set() + g = res['grid'] + lats = res['grid_lats'] + lons = res['grid_lons'] + + rows_idx, cols_idx = np.nonzero(g > 0) + + if len(rows_idx) > 0: + pixel_lats = lats[rows_idx] + pixel_lons = lons[cols_idx] + + y_vals = ((max_lat - pixel_lats) / (max_lat - min_lat) * (rows - 1)).astype(int) + x_vals = ((pixel_lons - min_lon) / (max_lon - min_lon) * (cols - 1)).astype(int) + + valid_mask = (y_vals >= 0) & (y_vals < rows) & (x_vals >= 0) & (x_vals < cols) + y_vals = y_vals[valid_mask] + x_vals = x_vals[valid_mask] + + if len(y_vals) > 0: + coords = np.column_stack((y_vals, x_vals)) + pixels = set(map(tuple, coords)) + + candidate_sets.append(pixels) + + # Greedy Loop + remaining_indices = list(range(len(all_node_results))) + + for _ in range(optimize_n): + best_idx = -1 + best_marginal_gain = -1 + + for idx in remaining_indices: + cand_pixels = candidate_sets[idx] + new_coverage = len(cand_pixels.difference(covered_points)) + + if new_coverage > best_marginal_gain: + best_marginal_gain = new_coverage + best_idx = idx + + if best_idx != -1 and best_marginal_gain > 0: + selected_results.append(all_node_results[best_idx]) + covered_points.update(candidate_sets[best_idx]) + remaining_indices.remove(best_idx) + else: + break + + # 3. Compute marginal coverage for each selected node + covered_so_far = set() + for res in selected_results: + g = res['grid'] + lats_g = res['grid_lats'] + lons_g = res['grid_lons'] + rows_idx, cols_idx = np.nonzero(g > 0) + node_pixels = set() + if len(rows_idx) > 0: + pixel_lats = lats_g[rows_idx] + pixel_lons = lons_g[cols_idx] + + y_vals = ((max_lat - pixel_lats) / (max_lat - min_lat) * (rows - 1)).astype(int) + x_vals = ((pixel_lons - min_lon) / (max_lon - min_lon) * (cols - 1)).astype(int) + + valid_mask = (y_vals >= 0) & (y_vals < rows) & (x_vals >= 0) & (x_vals < cols) + y_vals = y_vals[valid_mask] + x_vals = x_vals[valid_mask] + + if len(y_vals) > 0: + coords = np.column_stack((y_vals, x_vals)) + node_pixels = set(map(tuple, coords)) + 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 + if update_state_callback: + update_state_callback('PROGRESS', {'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'] + lons = res['grid_lons'] + + rows_idx, cols_idx = np.nonzero(g > 0) + if len(rows_idx) > 0: + pixel_lats = lats[rows_idx] + pixel_lons = lons[cols_idx] + + y_vals = ((max_lat - pixel_lats) / (max_lat - min_lat) * (rows - 1)).astype(int) + x_vals = ((pixel_lons - min_lon) / (max_lon - min_lon) * (cols - 1)).astype(int) + + valid_mask = (y_vals >= 0) & (y_vals < rows) & (x_vals >= 0) & (x_vals < cols) + y_vals = y_vals[valid_mask] + x_vals = x_vals[valid_mask] + + if len(y_vals) > 0: + master_grid[y_vals, x_vals] = 255 + + # 4. Generate PNG Base64 + height, width = master_grid.shape + rgba_grid = np.zeros((height, width, 4), dtype=np.uint8) + + cyan_r, cyan_g, cyan_b = 0, 242, 255 + opacity = 150 + + visible_mask = master_grid > 0 + + rgba_grid[visible_mask, 0] = cyan_r + rgba_grid[visible_mask, 1] = cyan_g + rgba_grid[visible_mask, 2] = cyan_b + rgba_grid[visible_mask, 3] = opacity + + img = Image.fromarray(rgba_grid, mode='RGBA') + buffered = BytesIO() + img.save(buffered, format="PNG") + img_str = base64.b64encode(buffered.getvalue()).decode() + + # 5. Build Final Output + final_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"], + "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 + 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", + "results": final_results, + "inter_node_links": inter_node_links, + "total_unique_coverage_km2": total_unique_km2, + "composite": { + "image": img_str, + "bounds": { + "north": max_lat, + "south": min_lat, + "east": max_lon, + "west": min_lon + } + } + } diff --git a/rf-engine/tasks/viewshed.py b/rf-engine/tasks/viewshed.py index 71f4c1b..f9887b8 100644 --- a/rf-engine/tasks/viewshed.py +++ b/rf-engine/tasks/viewshed.py @@ -1,24 +1,16 @@ from worker import celery_app -import time -import numpy as np - import os import redis -import json from celery.utils.log import get_task_logger -from core.algorithms import calculate_viewshed from tile_manager import TileManager -from models import NodeConfig -import rf_physics +from core.viewshed_proc import process_batch_viewshed logger = get_task_logger(__name__) # Re-init redis/tile_manager here for worker context -# Use ConnectionPool to prevent exhaust REDIS_HOST = os.environ.get("REDIS_HOST", "redis") REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "changeme") -# Global Pool pool = redis.ConnectionPool( host=REDIS_HOST, port=6379, @@ -35,364 +27,18 @@ def calculate_batch_viewshed(self, params): Calculate viewsheds for a list of nodes. params: { "nodes": [ {lat, lon, height, ...} ], "options": {"radius": 5000, "optimize_n": 3} } """ - from core.algorithms import calculate_viewshed - import base64 - from io import BytesIO - from PIL import Image - logger.info(f"Starting batch viewshed for {len(params.get('nodes', []))} nodes") self.update_state(state='PROGRESS', meta={'progress': 0, 'message': 'Initializing...'}) nodes_data = params.get('nodes', []) options = params.get('options', {}) - radius = float(options.get('radius', 5000)) - optimize_n = options.get('optimize_n') - rx_height = float(options.get('rx_height', 2.0)) - freq = float(options.get('frequency_mhz', 915.0)) - - # 1. Determine Bounding Box for Composite - if not nodes_data: - return {"status": "completed", "results": []} - - # Calculate center latitude for projection scaling - lats = [float(n['lat']) for n in nodes_data] - lons = [float(n['lon']) for n in nodes_data] - mean_lat = sum(lats) / len(lats) - - # Degrees per meter - # 1 deg lat is constant ~111.32 km - lat_deg_per_m = 1.0 / 111320.0 - # 1 deg lon depends on latitude: cos(lat) * 111.32 km - # Use max(0.001, ...) to avoid div by zero at poles (unlikely but safe) - lon_deg_per_m = 1.0 / (111320.0 * max(0.001, np.cos(np.radians(mean_lat)))) - - # Buffer: radius + 1km safety margin - buffer_m = radius + 1000 - buffer_lat = buffer_m * lat_deg_per_m - buffer_lon = buffer_m * lon_deg_per_m - - min_lat = min(lats) - buffer_lat - max_lat = max(lats) + buffer_lat - min_lon = min(lons) - buffer_lon - max_lon = max(lons) + buffer_lon - - # Define Global Master Grid - # Target resolution: 100m (default) - target_res_m = 100.0 - - # Calculate grid dimensions based on target resolution - # Height uses lat conversion, Width uses lon conversion (at mean lat) - # Height (m) = (max_lat - min_lat) / lat_deg_per_m - # Width (m) = (max_lon - min_lon) / lon_deg_per_m - - # But we can just divide deg difference by (res_m * deg_per_m) - rows = int((max_lat - min_lat) / (target_res_m * lat_deg_per_m)) - cols = int((max_lon - min_lon) / (target_res_m * lon_deg_per_m)) - - # Cap size to prevent OOM - # usage: 2048x2048 = 4MP = ~4MB (uint8) -> Safe - # 4096 is nice and big (16MB) - MAX_DIM = 4096 - - if rows > MAX_DIM or cols > MAX_DIM: - # Scale down resolution to fit - scale_factor = max(rows / MAX_DIM, cols / MAX_DIM) - # New resolution is larger (coarser) - res_m = target_res_m * scale_factor - - # Re-calc rows/cols - rows = int((max_lat - min_lat) / (res_m * lat_deg_per_m)) - cols = int((max_lon - min_lon) / (res_m * lon_deg_per_m)) - - logger.warning(f"Viewshed grid too large. Scaling resolution from {target_res_m}m to {res_m:.1f}m. Grid: {rows}x{cols}") - else: - res_m = target_res_m - - master_grid = np.zeros((rows, cols), dtype=np.uint8) - - # Coordinate mapping functions - def lat_to_y(lat): - return int((max_lat - lat) / (max_lat - min_lat) * (rows - 1)) - def lon_to_x(lon): - return int((lon - min_lon) / (max_lon - min_lon) * (cols - 1)) - - # Pre-calculate individual viewsheds if we need to optimize - # Or just calculate all and keep track of visibility - all_node_results = [] - - total = len(nodes_data) - for i, node_data in enumerate(nodes_data): - try: - lat = float(node_data.get('lat')) - lon = float(node_data.get('lon')) - height = float(node_data.get('height', 10)) - - # Simple viewshed - grid, grid_lats, grid_lons = calculate_viewshed( - tile_manager, lat, lon, height, radius, - rx_h=rx_height, freq_mhz=freq, resolution_m=res_m - ) - - coverage_count = int(np.sum(grid)) - source_elev = tile_manager.get_elevation(lat, 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, - "grid_lats": grid_lats, - "grid_lons": grid_lons - } - all_node_results.append(node_res) - - progress = int((i + 1) / total * 50) # First 50% for individual calcs - self.update_state(state='PROGRESS', meta={'progress': progress, 'message': f'Analyzed candidates {i+1}/{total}'}) - - except Exception as e: - logger.error(f"Error processing node {i}: {e}") - - # 2. Greedy Optimization (Marginal Gain) - selected_results = all_node_results - if optimize_n and 0 < optimize_n < len(all_node_results): - selected_results = [] - covered_points = set() # Set of (y, x) tuples on master_grid - - # Pre-compute pixel sets for all candidates - candidate_sets = [] - for res in all_node_results: - pixels = set() - g = res['grid'] - lats = res['grid_lats'] - lons = res['grid_lons'] - - # Optimization: Vectorized coordinate mapping - # Get indices where grid > 0 - rows_idx, cols_idx = np.nonzero(g > 0) - - if len(rows_idx) > 0: - # Vectorized lookup of lat/lon - pixel_lats = lats[rows_idx] - pixel_lons = lons[cols_idx] - - # Vectorized mapping to master grid coordinates - # y = int((max_lat - lat) / (max_lat - min_lat) * (rows - 1)) - y_vals = ((max_lat - pixel_lats) / (max_lat - min_lat) * (rows - 1)).astype(int) - - # x = int((lon - min_lon) / (max_lon - min_lon) * (cols - 1)) - x_vals = ((pixel_lons - min_lon) / (max_lon - min_lon) * (cols - 1)).astype(int) - - # Filter valid - valid_mask = (y_vals >= 0) & (y_vals < rows) & (x_vals >= 0) & (x_vals < cols) - y_vals = y_vals[valid_mask] - x_vals = x_vals[valid_mask] - - # Convert to set of tuples - # Creating a set of tuples from 2D array is still somewhat costly but faster than pure Python loop - if len(y_vals) > 0: - coords = np.column_stack((y_vals, x_vals)) - # Use a set comprehension or map for speed - pixels = set(map(tuple, coords)) - - candidate_sets.append(pixels) - - # Greedy Loop - remaining_indices = list(range(len(all_node_results))) - - for _ in range(optimize_n): - best_idx = -1 - best_marginal_gain = -1 - - for idx in remaining_indices: - cand_pixels = candidate_sets[idx] - # Calculate new pixels that are NOT in covered_points - # len(cand_pixels - covered_points) - # Set difference is fast - new_coverage = len(cand_pixels.difference(covered_points)) - - if new_coverage > best_marginal_gain: - best_marginal_gain = new_coverage - best_idx = idx - - if best_idx != -1 and best_marginal_gain > 0: - selected_results.append(all_node_results[best_idx]) - covered_points.update(candidate_sets[best_idx]) - remaining_indices.remove(best_idx) - else: - # No more gain to be had (or empty) - break - - # 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'] - rows_idx, cols_idx = np.nonzero(g > 0) - node_pixels = set() - if len(rows_idx) > 0: - pixel_lats = lats_g[rows_idx] - pixel_lons = lons_g[cols_idx] - - y_vals = ((max_lat - pixel_lats) / (max_lat - min_lat) * (rows - 1)).astype(int) - x_vals = ((pixel_lons - min_lon) / (max_lon - min_lon) * (cols - 1)).astype(int) - - valid_mask = (y_vals >= 0) & (y_vals < rows) & (x_vals >= 0) & (x_vals < cols) - y_vals = y_vals[valid_mask] - x_vals = x_vals[valid_mask] - - if len(y_vals) > 0: - coords = np.column_stack((y_vals, x_vals)) - node_pixels = set(map(tuple, coords)) - 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'] - lons = res['grid_lons'] - - # Vectorized Blit - rows_idx, cols_idx = np.nonzero(g > 0) - if len(rows_idx) > 0: - pixel_lats = lats[rows_idx] - pixel_lons = lons[cols_idx] - - y_vals = ((max_lat - pixel_lats) / (max_lat - min_lat) * (rows - 1)).astype(int) - x_vals = ((pixel_lons - min_lon) / (max_lon - min_lon) * (cols - 1)).astype(int) - - valid_mask = (y_vals >= 0) & (y_vals < rows) & (x_vals >= 0) & (x_vals < cols) - y_vals = y_vals[valid_mask] - x_vals = x_vals[valid_mask] - - if len(y_vals) > 0: - # Numpy advanced indexing for fast update - master_grid[y_vals, x_vals] = 255 # Visible - - # 4. Generate PNG Base64 (Neon Cyan RGBA) - # Create RGBA array - # rows, cols from master_grid.shape - height, width = master_grid.shape - rgba_grid = np.zeros((height, width, 4), dtype=np.uint8) - - # Define Neon Cyan: #00f2ff -> (0, 242, 255) - cyan_r, cyan_g, cyan_b = 0, 242, 255 - opacity = 150 # ~60% - - # Mask for visible pixels - visible_mask = master_grid > 0 - - # Apply colors where visible - rgba_grid[visible_mask, 0] = cyan_r - rgba_grid[visible_mask, 1] = cyan_g - rgba_grid[visible_mask, 2] = cyan_b - rgba_grid[visible_mask, 3] = opacity - - img = Image.fromarray(rgba_grid, mode='RGBA') - buffered = BytesIO() - img.save(buffered, format="PNG") - img_str = base64.b64encode(buffered.getvalue()).decode() - - # 5. Build Final Output - final_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"], - "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", - "results": final_results, - "inter_node_links": inter_node_links, - "total_unique_coverage_km2": total_unique_km2, - "composite": { - "image": img_str, - "bounds": { - "north": max_lat, - "south": min_lat, - "east": max_lon, - "west": min_lon - } - } - } + def update_state(state, meta): + self.update_state(state=state, meta=meta) + return process_batch_viewshed( + nodes_data, + options, + tile_manager, + update_state_callback=update_state + ) diff --git a/rf-engine/tests/test_viewshed_proc.py b/rf-engine/tests/test_viewshed_proc.py new file mode 100644 index 0000000..a92ea53 --- /dev/null +++ b/rf-engine/tests/test_viewshed_proc.py @@ -0,0 +1,42 @@ + +import pytest +from unittest.mock import MagicMock +import sys +import os +import numpy as np + +# Add parent directory to path to import modules +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.viewshed_proc import process_batch_viewshed + +class TestViewshedProc: + @pytest.fixture + def mock_tile_manager(self): + tm = MagicMock() + tm.get_elevation.return_value = 100.0 + tm.get_elevation_profile.return_value = [100.0] * 15 + return tm + + def test_process_batch_viewshed_basic(self, mock_tile_manager): + nodes = [{"lat": 45.0, "lon": -122.0, "height": 10, "name": "Test1"}] + options = {"radius": 1000, "optimize_n": 1} + + # Mock rf_physics calls + with pytest.MonkeyPatch.context() as m: + m.setattr("core.algorithms.calculate_viewshed", + lambda tm, lat, lon, h, r, rx_h, freq_mhz, resolution_m: ( + np.zeros((10, 10)), np.zeros(10), np.zeros(10) + ) + ) + + result = process_batch_viewshed(nodes, options, mock_tile_manager) + + assert result["status"] == "completed" + assert len(result["results"]) == 1 + assert "composite" in result + + def test_process_batch_viewshed_empty(self, mock_tile_manager): + result = process_batch_viewshed([], {}, mock_tile_manager) + assert result["status"] == "completed" + assert len(result["results"]) == 0 diff --git a/src/components/Map/LinkAnalysisPanel.jsx b/src/components/Map/LinkAnalysisPanel.jsx index 69ddc8d..49b1fa5 100644 --- a/src/components/Map/LinkAnalysisPanel.jsx +++ b/src/components/Map/LinkAnalysisPanel.jsx @@ -1,27 +1,34 @@ -import React, { useRef, useEffect } from 'react'; +import React, { useRef, useState } from 'react'; import PropTypes from 'prop-types'; import LinkProfileChart from './LinkProfileChart'; import { calculateBullingtonDiffraction } from '../../utils/rfMath'; - import { useRF } from '../../context/RFContext'; +import { useDraggablePanel } from '../../hooks/useDraggablePanel'; -const LinkAnalysisPanel = ({ nodes, linkStats, budget, distance, units, propagationSettings, setPropagationSettings }) => { - +import LinkStatusIndicator from './UI/LinkStatusIndicator'; +import LinkBudgetDisplay from './UI/LinkBudgetDisplay'; +import ModelComparisonTable from './UI/ModelComparisonTable'; +const LinkAnalysisPanel = ({ nodes, linkStats, budget, distance, units, propagationSettings, setPropagationSettings }) => { const { nodeConfigs, freq } = useRF(); const h1 = parseFloat(nodeConfigs.A.antennaHeight); const h2 = parseFloat(nodeConfigs.B.antennaHeight); + // Draggable hook + const { isMobile, panelSize, isResizing, handleMouseDown } = useDraggablePanel(); + const [isMinimized, setIsMinimized] = useState(false); + const [showModelHelp, setShowModelHelp] = useState(false); + const panelRef = useRef(null); + + if (nodes.length !== 2) return null; + // Conversions const isImperial = units === 'imperial'; const distDisplay = isImperial ? (distance * 0.621371).toFixed(2) + ' mi' : distance.toFixed(2) + ' km'; const clearanceVal = linkStats.minClearance; const clearanceDisplay = isImperial ? (clearanceVal * 3.28084).toFixed(1) + ' ft' : clearanceVal + ' m'; - // Colors - const isObstructed = linkStats.isObstructed; - - // Calculate Diffraction Loss if using Hata and we have a profile + // Calculate Diffraction Loss let diffractionLoss = 0; if (linkStats.profileWithStats) { diffractionLoss = calculateBullingtonDiffraction( @@ -33,79 +40,41 @@ const LinkAnalysisPanel = ({ nodes, linkStats, budget, distance, units, propagat } let margin = budget ? budget.margin : 0; - // WISP Ratings + // Determine RF Status const quality = linkStats.linkQuality || 'Obstructed (-)'; - - // Determine RF Status based on calibrated LoRa thresholds let rfColor = '#ff0000'; let rfText = 'NO SIGNAL'; if (margin >= 10) { - rfColor = '#00ff41'; - rfText = 'EXCELLENT +++'; + rfColor = '#00ff41'; rfText = 'EXCELLENT +++'; } else if (margin >= 5) { - rfColor = '#00ff41'; - rfText = 'GOOD ++'; + rfColor = '#00ff41'; rfText = 'GOOD ++'; } else if (margin >= 0) { - rfColor = '#eeff00ff'; - rfText = 'FAIR +'; + rfColor = '#eeff00'; rfText = 'FAIR +'; } else if (margin >= -10) { - rfColor = '#ffbf00'; - rfText = 'MARGINAL -+'; + rfColor = '#ffbf00'; rfText = 'MARGINAL -+'; } else if (margin < -10) { - rfColor = '#ff0000'; - rfText = 'NO SIGNAL -'; + rfColor = '#ff0000'; rfText = 'NO SIGNAL -'; } let statusColor = rfColor; let statusText = rfText; - // Apply Model Constraints - // Fixed: Always report obstruction if physically obstructed, regardless of model if (quality.includes('Obstructed')) { statusColor = '#ff0000'; statusText = 'OBSTRUCTED (LOS)'; } else if (diffractionLoss > 10) { - // If loss is huge (NLOS), override status even if LOS is technically valid (grazing) statusColor = '#ff0000'; statusText = 'Diffraction Limited'; } - // Responsive Chart Logic - const [isMobile, setIsMobile] = React.useState(window.innerWidth < 768); - const [panelSize, setPanelSize] = React.useState({ - width: isMobile ? window.innerWidth : 400, - height: isMobile ? 480 : 650 - }); - - React.useEffect(() => { - const handleResize = () => { - const mobile = window.innerWidth < 768; - setIsMobile(mobile); - if (mobile) { - setPanelSize({ width: window.innerWidth, height: 480 }); - } - }; - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); - - const panelRef = React.useRef(null); - const draggingRef = React.useRef(false); - const [isResizing, setIsResizing] = React.useState(false); // Used to disable transition during drag - const [isMinimized, setIsMinimized] = React.useState(false); - const [showModelHelp, setShowModelHelp] = React.useState(false); - const lastPosRef = React.useRef({ x: 0, y: 0 }); - - if (nodes.length !== 2) return null; - // Calculate Dimensions directly (Derived State) - let layoutOffset = 380; // Recalibrated to eliminate dead space + let layoutOffset = 380; if (propagationSettings && propagationSettings.model === 'hata' && (h1 < 30 || distance < 1 || distance > 20 || freq < 150 || freq > 1500)) { - layoutOffset += 60; // Extra room for Hata warnings + layoutOffset += 60; } if (diffractionLoss > 0) { - layoutOffset += 70; // Extra room for obstruction box + layoutOffset += 70; } const dimensions = { @@ -113,61 +82,6 @@ const LinkAnalysisPanel = ({ nodes, linkStats, budget, distance, units, propagat height: Math.max(100, panelSize.height - layoutOffset) }; - // Resize Handler - const cleanupRef = useRef(null); - - const handleMouseDown = (e) => { - draggingRef.current = true; - setIsResizing(true); - lastPosRef.current = { x: e.clientX, y: e.clientY }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - - // Fix 12: Store cleanup function - cleanupRef.current = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('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 => { - const newWidth = prev.width - dx; - const newHeight = prev.height + dy; - - return { - width: Math.max(400, newWidth), - height: Math.max(500, newHeight) - }; - }); - }; - - const handleMouseUp = () => { - draggingRef.current = false; - setIsResizing(false); - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - cleanupRef.current = null; - }; - - // Fix 12: Cleanup on unmount - useEffect(() => { - return () => { - if (cleanupRef.current) { - cleanupRef.current(); - } - }; - }, []); - return (
- {/* help slide-down - RE-INTEGRATED INTO PANEL AT ROOT LEVEL */} - {showModelHelp && ( -
-
- - - - - - Propagation Model Guide -
-
- The engine uses physical models to predict signal strength across the terrain. -
-
-
-
- Quick Guide: -
-
- Longley-Rice ITM: Full NTIA implementation running in browser (WASM). Uses terrain, diffraction, and troposcatter. Most accurate. -
-
- FSPL: Idealized "Line of Sight" calculation. Best for very short distances or space-to-earth links. -
-
- Okumura-Hata: Statistical model based on city measurements. Accounts for clutter and building density. -
-
- Bullington: Primary terrain-aware model for terrestrial links. Accounts for diffraction over hills. -
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
ModelRecommended Use Case
ITM (WASM)General Purpose / Accurate
FSPLBench tests & Space links
HataCity-wide Mesh Planning
BullingtonLong-range Rural / Hills
-
-
- - - - Propagation Analysis Note -
-
-
- Statistical (Hata) ignores terrain and assumes flat ground. This results in heavy signal penalties for high-elevation links. -
-
- Terrain (Bullington) accounts for hills and clear line-of-sight, providing accurate high-performance predictions for Mesh nodes. -
-
-
-
- -
+ {showModelHelp && ( + setShowModelHelp(false)} + propagationSettings={propagationSettings} + /> )} - {/* Mobile Grab Handle & Clickable Header Area */} - {isMobile && ( -
setIsMinimized(!isMinimized)} - style={{ - padding: '12px 0 8px 0', - cursor: 'pointer', - width: '100%', - flexShrink: 0, - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - gap: '8px' - }} - title={isMinimized ? "Expand" : "Minimize"} - > -
- - {isMinimized && ( -
- - {statusText} - - | - - {margin} dB Margin - -
- )} -
- )} - {/* Custom Bottom-Left Resize Handle - Only on Desktop */} - {!isMobile && ( -
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" - >
- )} + + + {!isMinimized && ( + <> + {/* Custom Bottom-Left Resize Handle - Only on Desktop */} + {!isMobile && ( +
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 - Also clickable on mobile to toggle */} -
setIsMinimized(!isMinimized) : undefined} - style={{ - display: isMinimized && isMobile ? 'none' : 'flex', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: '12px', - cursor: isMobile ? 'pointer' : 'default', - flexShrink: 0 - }} - > -
-

Link Analysis

- {isMobile && ( - - ▼ - - )} -
- - {statusText} - -
+ {/* Propagation Configuration */} + {propagationSettings && ( +
+
+ {/* Row 1: Model & Help */} +
+
+ + +
- {/* Propagation Configuration */} - {propagationSettings && ( -
-
- {/* Row 1: Model & Help */} -
-
- - -
+ {/* Model Info Tooltip */} +
setShowModelHelp(!showModelHelp)} + style={{ + position: 'relative', + cursor: 'pointer', + color: '#00f2ff', + fontSize: '0.85em', + display: 'flex', + alignItems: 'center', + padding: '4px', + background: showModelHelp ? 'rgba(0, 242, 255, 0.1)' : 'transparent', + borderRadius: '4px', + gap: '4px' + }} title="Click for Model Comparison Guide"> + + + + + + + {showModelHelp ? 'Hide Info' : ( + propagationSettings.model === 'bullington' ? 'Bullington Info' : + propagationSettings.model === 'hata' ? 'Hata Info' : + propagationSettings.model === 'itm_wasm' ? 'ITM Info' : + propagationSettings.model === 'fspl' ? 'LOS Info' : + 'Model Info' + )} + +
+
- {/* Model Info Tooltip moved here */} -
setShowModelHelp(!showModelHelp)} + {/* Row 2: Env Selector */} +
- - - - - - - {showModelHelp ? 'Hide Info' : ( - propagationSettings.model === 'bullington' ? 'Bullington Info' : - propagationSettings.model === 'hata' ? 'Hata Info' : - propagationSettings.model === 'itm_wasm' ? 'ITM Info' : - propagationSettings.model === 'fspl' ? 'LOS Info' : - 'Model Info' - )} - -
-
- - {/* Row 2: Env Selector */} -
- - -
- - {/* Hata Validity Warnings - COMPACT OVERLAY */} - {propagationSettings.model === 'hata' && ( -
- {(distance < 1 || distance > 20) && ( -
- - Dist {distance.toFixed(1)}km (Limit 1-20km) -
- )} - {h1 < 30 && ( -
- - TX {h1}m < 30m (Hata Min) -
- )} - {(freq < 150 || freq > 1500) && ( -
- - Freq {freq}MHz (Limit 150-1500) -
- )} + +
- )} -
- - -
- )} - - {/* Stats Grid */} -
-
-
Distance
-
{distDisplay}
-
-
-
Margin
-
{margin} dB
-
-
-
RSSI
-
{budget ? budget.rssi : '--'} dBm
-
-
-
First Fresnel
-
{clearanceDisplay}
-
- {diffractionLoss > 0 && ( -
-
Obstruction Loss
-
-{diffractionLoss} dB
+ {/* Hata Validity Warnings */} + {propagationSettings.model === 'hata' && ( +
+ {(distance < 1 || distance > 20) && ( +
+ + Dist {distance.toFixed(1)}km (Limit 1-20km) +
+ )} + {h1 < 30 && ( +
+ + TX {h1}m < 30m (Hata Min) +
+ )} + {(freq < 150 || freq > 1500) && ( +
+ + Freq {freq}MHz (Limit 150-1500) +
+ )} +
+ )} +
)} -
- {/* Profile Chart - Flexible Height */} -
-
Terrain & Path Profile
- {linkStats.loading ? ( -
- Loading Elevation Data... + + + {/* Profile Chart - Flexible Height */} +
+
Terrain & Path Profile
+ {linkStats.loading ? ( +
+ Loading Elevation Data... +
+ ) : ( +
+ +
+ )} +
+ + {/* Legend / Info */} +
+
+
+ LOS
- ) : ( -
- +
+
+ Terrain +
+
+
+ Fresnel
- )} -
- - {/* Legend / Info */} -
-
-
- LOS -
-
-
- Terrain -
-
-
- Fresnel
-
+ + )}
); }; diff --git a/src/components/Map/UI/LinkBudgetDisplay.jsx b/src/components/Map/UI/LinkBudgetDisplay.jsx new file mode 100644 index 0000000..8fc7b56 --- /dev/null +++ b/src/components/Map/UI/LinkBudgetDisplay.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const LinkBudgetDisplay = ({ distDisplay, margin, statusColor, budget, clearanceDisplay, diffractionLoss }) => { + return ( +
+
+
Distance
+
{distDisplay}
+
+
+
Margin
+
{margin} dB
+
+
+
RSSI
+
{budget ? budget.rssi : '--'} dBm
+
+
+
First Fresnel
+
{clearanceDisplay}
+
+ {diffractionLoss > 0 && ( +
+
Obstruction Loss
+
-{diffractionLoss} dB
+
+ )} +
+ ); +}; + +LinkBudgetDisplay.propTypes = { + distDisplay: PropTypes.string.isRequired, + margin: PropTypes.number.isRequired, + statusColor: PropTypes.string.isRequired, + budget: PropTypes.object, + clearanceDisplay: PropTypes.string.isRequired, + diffractionLoss: PropTypes.number.isRequired +}; + +export default LinkBudgetDisplay; diff --git a/src/components/Map/UI/LinkStatusIndicator.jsx b/src/components/Map/UI/LinkStatusIndicator.jsx new file mode 100644 index 0000000..240b291 --- /dev/null +++ b/src/components/Map/UI/LinkStatusIndicator.jsx @@ -0,0 +1,93 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const LinkStatusIndicator = ({ isMobile, isMinimized, setIsMinimized, statusColor, statusText, margin }) => { + return ( + <> + {/* Mobile Grab Handle & Clickable Header Area */} + {isMobile && ( +
setIsMinimized(!isMinimized)} + style={{ + padding: '12px 0 8px 0', + cursor: 'pointer', + width: '100%', + flexShrink: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '8px' + }} + title={isMinimized ? "Expand" : "Minimize"} + > +
+ + {isMinimized && ( +
+ + {statusText} + + | + + {margin} dB Margin + +
+ )} +
+ )} + + {/* Header - Also clickable on mobile to toggle */} +
setIsMinimized(!isMinimized) : undefined} + style={{ + display: isMinimized && isMobile ? 'none' : 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '12px', + cursor: isMobile ? 'pointer' : 'default', + flexShrink: 0 + }} + > +
+

Link Analysis

+ {isMobile && ( + + ▼ + + )} +
+ + {statusText} + +
+ + ); +}; + +LinkStatusIndicator.propTypes = { + isMobile: PropTypes.bool.isRequired, + isMinimized: PropTypes.bool.isRequired, + setIsMinimized: PropTypes.func.isRequired, + statusColor: PropTypes.string.isRequired, + statusText: PropTypes.string.isRequired, + margin: PropTypes.number.isRequired +}; + +export default LinkStatusIndicator; diff --git a/src/components/Map/UI/ModelComparisonTable.jsx b/src/components/Map/UI/ModelComparisonTable.jsx new file mode 100644 index 0000000..dda2b14 --- /dev/null +++ b/src/components/Map/UI/ModelComparisonTable.jsx @@ -0,0 +1,130 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const ModelComparisonTable = ({ onClose, propagationSettings }) => { + return ( +
+
+ + + + + + Propagation Model Guide +
+
+ The engine uses physical models to predict signal strength across the terrain. +
+
+
+
+ Quick Guide: +
+
+ Longley-Rice ITM: Full NTIA implementation running in browser (WASM). Uses terrain, diffraction, and troposcatter. Most accurate. +
+
+ FSPL: Idealized "Line of Sight" calculation. Best for very short distances or space-to-earth links. +
+
+ Okumura-Hata: Statistical model based on city measurements. Accounts for clutter and building density. +
+
+ Bullington: Primary terrain-aware model for terrestrial links. Accounts for diffraction over hills. +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
ModelRecommended Use Case
ITM (WASM)General Purpose / Accurate
FSPLBench tests & Space links
HataCity-wide Mesh Planning
BullingtonLong-range Rural / Hills
+ +
+
+ + + + Propagation Analysis Note +
+
+
+ Statistical (Hata) ignores terrain and assumes flat ground. This results in heavy signal penalties for high-elevation links. +
+
+ Terrain (Bullington) accounts for hills and clear line-of-sight, providing accurate high-performance predictions for Mesh nodes. +
+
+
+
+ +
+ ); +}; + +ModelComparisonTable.propTypes = { + onClose: PropTypes.func.isRequired, + propagationSettings: PropTypes.object +}; + +export default ModelComparisonTable; diff --git a/src/components/Map/UI/SiteAnalysis/LinksTab.jsx b/src/components/Map/UI/SiteAnalysis/LinksTab.jsx new file mode 100644 index 0000000..38e9323 --- /dev/null +++ b/src/components/Map/UI/SiteAnalysis/LinksTab.jsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { STATUS_COLORS, STATUS_LABELS } from '../../../../utils/meshTopology'; + +function statusBadge(status) { + const color = STATUS_COLORS[status] || STATUS_COLORS.unknown; + return ( + + {STATUS_LABELS[status] || status} + + ); +} + +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)}%`} +
+
+
+
+ ); + })} +
+ ); +} + +export default LinksTab; diff --git a/src/components/Map/UI/SiteAnalysis/SitesTab.jsx b/src/components/Map/UI/SiteAnalysis/SitesTab.jsx new file mode 100644 index 0000000..6806a0e --- /dev/null +++ b/src/components/Map/UI/SiteAnalysis/SitesTab.jsx @@ -0,0 +1,92 @@ +import React from 'react'; + +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 +
+
+
+
+ ); + })} +
+ ); +} + +export default SitesTab; diff --git a/src/components/Map/UI/SiteAnalysis/TopologyTab.jsx b/src/components/Map/UI/SiteAnalysis/TopologyTab.jsx new file mode 100644 index 0000000..56f97c2 --- /dev/null +++ b/src/components/Map/UI/SiteAnalysis/TopologyTab.jsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { findMeshPaths, STATUS_COLORS, STATUS_LABELS } from '../../../../utils/meshTopology'; + +function statusBadge(status) { + const color = STATUS_COLORS[status] || STATUS_COLORS.unknown; + return ( + + {STATUS_LABELS[status] || status} + + ); +} + +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 pathStr = p.path.map(idx => results[idx]?.name || `Site ${idx + 1}`).join(' → '); + return ( +
+ + {pathStr} + +
+ {p.hops > 1 && ( + {p.hops} hops + )} + {statusBadge(p.status)} +
+
+ ); + })} + + )} +
+ ); +} + +export default TopologyTab; diff --git a/src/components/Map/UI/SiteAnalysisResultsPanel.jsx b/src/components/Map/UI/SiteAnalysisResultsPanel.jsx index 3358c51..1107199 100644 --- a/src/components/Map/UI/SiteAnalysisResultsPanel.jsx +++ b/src/components/Map/UI/SiteAnalysisResultsPanel.jsx @@ -1,362 +1,7 @@ import React, { useState, useEffect } from 'react'; - -// ─── 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)} -
-
- ); - })} - - )} -
- ); -} +import SitesTab from './SiteAnalysis/SitesTab'; +import LinksTab from './SiteAnalysis/LinksTab'; +import TopologyTab from './SiteAnalysis/TopologyTab'; // ─── Main Panel ─────────────────────────────────────────────────────────────── @@ -366,7 +11,7 @@ const SiteAnalysisResultsPanel = ({ results, interNodeLinks, totalUniqueCoverageKm2, - onClose, + onClose, // Kept for API compatibility, though not used in UI currently onCenter, onClear, onRunNew, diff --git a/src/hooks/useDraggablePanel.js b/src/hooks/useDraggablePanel.js new file mode 100644 index 0000000..e408ce7 --- /dev/null +++ b/src/hooks/useDraggablePanel.js @@ -0,0 +1,82 @@ +import { useState, useRef, useEffect } from 'react'; + +export const useDraggablePanel = (initialWidth = 400, initialHeight = 650) => { + const [isMobile, setIsMobile] = useState(window.innerWidth < 768); + const [panelSize, setPanelSize] = useState({ + width: isMobile ? window.innerWidth : initialWidth, + height: isMobile ? 480 : initialHeight + }); + const [isResizing, setIsResizing] = useState(false); + const draggingRef = useRef(false); + const lastPosRef = useRef({ x: 0, y: 0 }); + const cleanupRef = useRef(null); + + useEffect(() => { + const handleResize = () => { + const mobile = window.innerWidth < 768; + setIsMobile(mobile); + if (mobile) { + setPanelSize({ width: window.innerWidth, height: 480 }); + } + }; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + const handleMouseDown = (e) => { + draggingRef.current = true; + setIsResizing(true); + lastPosRef.current = { x: e.clientX, y: e.clientY }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + cleanupRef.current = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + e.preventDefault(); + }; + + 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 => { + const newWidth = prev.width - dx; + const newHeight = prev.height + dy; + + return { + width: Math.max(400, newWidth), + height: Math.max(300, newHeight) + }; + }); + }; + + const handleMouseUp = () => { + draggingRef.current = false; + setIsResizing(false); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + cleanupRef.current = null; + }; + + useEffect(() => { + return () => { + if (cleanupRef.current) { + cleanupRef.current(); + } + }; + }, []); + + return { + isMobile, + panelSize, + isResizing, + handleMouseDown + }; +}; diff --git a/src/utils/meshTopology.js b/src/utils/meshTopology.js new file mode 100644 index 0000000..1d0c587 --- /dev/null +++ b/src/utils/meshTopology.js @@ -0,0 +1,68 @@ +// ─── Helpers ────────────────────────────────────────────────────────────────── + +export const STATUS_COLORS = { + viable: '#00f2ff', + degraded: '#ffd700', + blocked: '#ff4444', + unknown: '#888', +}; + +export const STATUS_LABELS = { + viable: 'Viable', + degraded: 'Degraded', + blocked: 'Blocked', + unknown: 'Unknown', +}; + +/** Build adjacency list and find all viable multi-hop paths between every pair */ +export 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; +}