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.
-
-
-
-
-
- | Model |
- Recommended Use Case |
-
-
-
-
- | ITM (WASM) |
- General Purpose / Accurate |
-
-
- | FSPL |
- Bench tests & Space links |
-
-
- | Hata |
- City-wide Mesh Planning |
-
-
- | Bullington |
- Long-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}
-
-
-
-
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 */}
+
+
- ) : (
-
-
- {/* Legend / Info */}
-
+ >
+ )}
);
};
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}
+
+
+
+
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.
+
+
+
+
+
+ | Model |
+ Recommended Use Case |
+
+
+
+
+ | ITM (WASM) |
+ General Purpose / Accurate |
+
+
+ | FSPL |
+ Bench tests & Space links |
+
+
+ | Hata |
+ City-wide Mesh Planning |
+
+
+ | Bullington |
+ Long-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 }) => (
+
+ ))}
+
+
+ {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 }) => (
-
- ))}
-
-
- {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;
+}