From 9efb7d9aa9451cdfad80a0101df92896330a74b7 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 03:50:57 +0000 Subject: [PATCH] Refactor Frontend Core: Split MapContainer and Sidebar into modular components - Split `RFContext.jsx` into focused contexts (UI, Hardware, Environment, Radio) using a Facade pattern. - Refactored `Sidebar.jsx` into `HardwareSection`, `EnvironmentSection`, `LoRaBandSection`, `SettingsSection`. - Refactored `MapContainer.jsx` into `LinkLayerManager`, `ViewshedLayerManager`, `CoverageLayerManager`, `OptimizationLayerManager`. - Created custom hooks `useLinkTool` and `useMapEventHandlers` to manage map state. - Updated `REFACTORING_REPORT.md` to reflect Phase 1 completion. - Fixed circular dependency warnings by using synchronous updates in handlers instead of effects. Co-authored-by: d3mocide <136547209+d3mocide@users.noreply.github.com> --- REFACTORING_REPORT.md | 167 +-- package-lock.json | 31 +- src/components/Layout/Sidebar.jsx | 956 +++---------- .../Layout/sections/CollapsibleSection.jsx | 56 + .../Layout/sections/EnvironmentSection.jsx | 129 ++ .../Layout/sections/HardwareSection.jsx | 299 +++++ .../Layout/sections/LoRaBandSection.jsx | 126 ++ .../Layout/sections/SettingsSection.jsx | 89 ++ src/components/Map/MapContainer.jsx | 1179 ++++------------- src/components/Map/hooks/useLinkTool.js | 119 ++ .../Map/hooks/useMapEventHandlers.js | 49 + .../Map/layers/CoverageLayerManager.jsx | 215 +++ .../Map/layers/LinkLayerManager.jsx | 56 + .../Map/layers/OptimizationLayerManager.jsx | 164 +++ .../Map/layers/ViewshedLayerManager.jsx | 97 ++ src/context/EnvironmentContext.jsx | 51 + src/context/HardwareContext.jsx | 104 ++ src/context/RFContext.jsx | 345 +---- src/context/RadioContext.jsx | 43 + src/context/UIContext.jsx | 43 + 20 files changed, 2168 insertions(+), 2150 deletions(-) create mode 100644 src/components/Layout/sections/CollapsibleSection.jsx create mode 100644 src/components/Layout/sections/EnvironmentSection.jsx create mode 100644 src/components/Layout/sections/HardwareSection.jsx create mode 100644 src/components/Layout/sections/LoRaBandSection.jsx create mode 100644 src/components/Layout/sections/SettingsSection.jsx create mode 100644 src/components/Map/hooks/useLinkTool.js create mode 100644 src/components/Map/hooks/useMapEventHandlers.js create mode 100644 src/components/Map/layers/CoverageLayerManager.jsx create mode 100644 src/components/Map/layers/LinkLayerManager.jsx create mode 100644 src/components/Map/layers/OptimizationLayerManager.jsx create mode 100644 src/components/Map/layers/ViewshedLayerManager.jsx create mode 100644 src/context/EnvironmentContext.jsx create mode 100644 src/context/HardwareContext.jsx create mode 100644 src/context/RadioContext.jsx create mode 100644 src/context/UIContext.jsx diff --git a/REFACTORING_REPORT.md b/REFACTORING_REPORT.md index b1a09db..aa5b5fa 100644 --- a/REFACTORING_REPORT.md +++ b/REFACTORING_REPORT.md @@ -24,28 +24,28 @@ MeshRF is a full-stack RF propagation and link analysis application for LoRa mes ## Files by Size (All Files ≥ 200 Lines) -| Rank | File | Lines | Priority | -|------|------|-------|----------| -| 1 | `src/components/Map/MapContainer.jsx` | 1173 | CRITICAL | -| 2 | `src/components/Layout/Sidebar.jsx` | 829 | CRITICAL | -| 3 | `src/components/Map/LinkAnalysisPanel.jsx` | 643 | HIGH | -| 4 | `src/components/Map/UI/SiteAnalysisResultsPanel.jsx` | 609 | HIGH | -| 5 | `src/components/Map/OptimizationLayer.jsx` | 517 | HIGH | -| 6 | `rf-engine/server.py` | 475 | HIGH | -| 7 | `src/components/Map/UI/NodeManager.jsx` | 440 | MEDIUM | -| 8 | `src/components/Map/OptimizationResultsPanel.jsx` | 435 | MEDIUM | -| 9 | `src/components/Map/LinkLayer.jsx` | 429 | MEDIUM | -| 10 | `rf-engine/tasks/viewshed.py` | 398 | MEDIUM | -| 11 | `src/utils/rfMath.js` | 366 | LOW | -| 12 | `src/components/Map/BatchNodesPanel.jsx` | 354 | MEDIUM | -| 13 | `src/hooks/useViewshedTool.js` | 343 | MEDIUM | -| 14 | `rf-engine/tile_manager.py` | 334 | MEDIUM | -| 15 | `src/components/Map/BatchProcessing.jsx` | 321 | LOW | -| 16 | `src/components/Map/UI/GuidanceOverlays.jsx` | 318 | LOW | -| 17 | `src/context/RFContext.jsx` | 307 | MEDIUM | -| 18 | `src/hooks/useRFCoverageTool.js` | 277 | LOW | -| 19 | `src/components/Map/Controls/ViewshedControl.jsx` | 225 | LOW | -| 20 | `rf-engine/rf_physics.py` | 221 | LOW | +| Rank | File | Lines | Priority | Status | +|------|------|-------|----------|--------| +| 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 | +| 5 | `src/components/Map/OptimizationLayer.jsx` | 517 | HIGH | Pending | +| 6 | `rf-engine/server.py` | 475 | HIGH | Pending | +| 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 | +| 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 | +| 14 | `rf-engine/tile_manager.py` | 334 | MEDIUM | Pending | +| 15 | `src/components/Map/BatchProcessing.jsx` | 321 | LOW | Pending | +| 16 | `src/components/Map/UI/GuidanceOverlays.jsx` | 318 | LOW | Pending | +| 17 | `src/context/RFContext.jsx` | 307 | MEDIUM | **REFACTORED** (Facade) | +| 18 | `src/hooks/useRFCoverageTool.js` | 277 | LOW | Pending | +| 19 | `src/components/Map/Controls/ViewshedControl.jsx` | 225 | LOW | Pending | +| 20 | `rf-engine/rf_physics.py` | 221 | LOW | Pending | --- @@ -57,62 +57,29 @@ MeshRF is a full-stack RF propagation and link analysis application for LoRa mes #### 1. `src/components/Map/MapContainer.jsx` — 1173 lines -**What it does**: The main map orchestrator. Manages all map layers, tool modes, user interactions, and coordinates between Leaflet, WASM workers, and React state. - -**Why it's a problem**: At 1173 lines it violates single-responsibility badly. It contains map config, event handlers, layer rendering logic, tool state machines, and full JSX output — all in one file. Changes to any one tool risk breaking others. - -**Logical sections**: -1. Imports and SVG icon definitions (lines 1–50) -2. Map initialization and state setup (lines 60–150) -3. Tool event handlers and callbacks (lines 160–350) -4. Layer rendering conditions (lines 360–600) -5. Main JSX render (lines 610–1173) - -**Suggested split**: - -``` -src/components/Map/ -├── MapContainer.jsx (~200 lines) — core orchestration only -├── config/ -│ └── MapConfig.js (~50 lines) — Leaflet tile/options config -├── hooks/ -│ └── useMapEventHandlers.js (~150 lines) — click, drag, keypress handlers -├── layers/ -│ ├── ViewshedLayerManager.jsx (~150 lines) -│ ├── CoverageLayerManager.jsx (~150 lines) -│ ├── OptimizationLayerManager.jsx (~150 lines) -│ └── LinkLayerManager.jsx (~150 lines) -``` - -**Target**: MapContainer.jsx shrinks to ~200 lines of pure composition. +**Status**: Refactored (Phase 1b) +- **Extracted Managers**: + - `LinkLayerManager.jsx`: Handles Link Layer and Panel. + - `ViewshedLayerManager.jsx`: Handles Viewshed Layer, Marker, Control. + - `CoverageLayerManager.jsx`: Handles RF Coverage Layer, Marker, Recalc Logic. + - `OptimizationLayerManager.jsx`: Handles Optimization Layer, Multi-site clicks, Simulation results. +- **Extracted Hooks**: + - `useLinkTool.js`: Manages link state (nodes, stats, locking). + - `useMapEventHandlers.js`: Manages map click/interaction logic. +- **Result**: `MapContainer.jsx` is now a high-level orchestrator focusing on coordinating tools and layers. --- #### 2. `src/components/Layout/Sidebar.jsx` — 829 lines -**What it does**: Configuration panel for RF hardware, environment, LoRa band settings, batch processing, and map options. - -**Why it's a problem**: A single component rendering 6 logically distinct panels. Each panel has its own state, event handlers, and JSX. Changes to hardware settings risk breaking environment settings and vice versa. - -**Logical sections**: -1. `CollapsibleSection` helper component (lines 6–57) -2. State and hooks (lines 59–166) -3. Hardware configuration (lines 300–535) -4. Environment settings (lines 537–625) -5. LoRa band settings (lines 627–707) -6. Batch + settings footer (lines 711–829) - -**Suggested split**: - -``` -src/components/Layout/ -├── Sidebar.jsx (~150 lines) — layout and section composition -└── sections/ - ├── HardwareSection.jsx (~200 lines) — device, antenna, power, cable - ├── EnvironmentSection.jsx (~150 lines) — ground type, climate, k-factor - ├── LoRaBandSection.jsx (~150 lines) — frequency, BW, SF, CR - └── CollapsibleSection.jsx (~60 lines) — reusable wrapper -``` +**Status**: Refactored (Phase 1a) +- **Extracted Sections**: + - `HardwareSection.jsx`: Device, Antenna, Power, Cable settings. + - `EnvironmentSection.jsx`: ITM params (Ground, Climate, K-Factor). + - `LoRaBandSection.jsx`: Radio settings (Freq, BW, SF, CR). + - `SettingsSection.jsx`: Global settings (Units, Map Style). +- **Reusable Component**: `CollapsibleSection.jsx`. +- **Result**: `Sidebar.jsx` is now a clean layout container composing these sections. --- @@ -307,18 +274,14 @@ rf-engine/ #### 12. `src/context/RFContext.jsx` — 307 lines -**What it does**: Central React Context holding all RF configuration state — hardware, radio, environment, and UI state — shared across all components. - -**Suggested split**: - -``` -src/context/ -├── RFContext.jsx (~80 lines) — root provider composing all contexts -├── HardwareContext.jsx (~80 lines) — Node A/B antenna/device config -├── EnvironmentContext.jsx (~60 lines) — ITM model parameters -├── RadioContext.jsx (~60 lines) — LoRa band settings -└── UIContext.jsx (~60 lines) — tool mode, sidebar, edit mode -``` +**Status**: Refactored (Phase 1) +- Implemented **Facade Strategy**: + - `UIContext.jsx`: UI state. + - `HardwareContext.jsx`: Node configs. + - `EnvironmentContext.jsx`: ITM params. + - `RadioContext.jsx`: LoRa settings. + - `RFContext.jsx`: Wrapper that composes these contexts and exports a unified hook. +- **Result**: Clean separation of concerns while maintaining backward compatibility. --- @@ -350,21 +313,17 @@ src/hooks/ --- -## Recommended Refactoring Phases - -### Phase 1 — Frontend Core (Highest ROI) - -Target the two largest files to eliminate the "big ball of mud" problem: +## Refactoring Progress -1. **MapContainer.jsx** (1173 → ~200 lines): Extract 4 layer manager components and a `useMapEventHandlers` hook. This is the highest-risk file for accidental regressions. -2. **Sidebar.jsx** (829 → ~150 lines): Extract 3 section components and the `CollapsibleSection` helper. +### Phase 1 — Frontend Core (COMPLETED) -**Expected effort**: 3–5 days -**Risk**: Medium — both files have complex state wiring; test thoroughly after each split. +1. **MapContainer.jsx** (1173 → ~250 lines): Refactored into Layer Managers (`LinkLayerManager`, `ViewshedLayerManager`, `CoverageLayerManager`, `OptimizationLayerManager`) and Hooks (`useLinkTool`, `useMapEventHandlers`). +2. **Sidebar.jsx** (829 → ~200 lines): Refactored into Sections (`HardwareSection`, `EnvironmentSection`, `LoRaBandSection`, `SettingsSection`). +3. **RFContext.jsx**: Refactored using Facade pattern (`UIContext`, `HardwareContext`, `EnvironmentContext`, `RadioContext`). --- -### Phase 2 — Backend API Structure +### Phase 2 — Backend API Structure (NEXT) Reorganize `server.py` into FastAPI routers — this is low-risk since Python imports are explicit and easy to verify: @@ -383,13 +342,13 @@ Reorganize `server.py` into FastAPI routers — this is low-risk since Python im 7. **viewshed.py** (398 → ~120 lines): Separate Celery task definition from processing logic and image utilities. **Expected effort**: 2–3 days -**Risk**: Medium — analysis panels have complex prop drilling; consider whether this is a good time to introduce a data-fetching pattern (e.g., React Query). +**Risk**: Medium — analysis panels have complex prop drilling. --- ### Phase 4 — State Management -8. **RFContext.jsx** (307 → ~80 lines each): Split into 4 focused contexts. This change affects nearly every component, so coordinate with Phase 1 changes. +8. **RFContext.jsx** (307 → ~80 lines each): Split into 4 focused contexts. This change affects nearly every component, so coordinate with Phase 1 changes. (ALREADY COMPLETED IN PHASE 1 VIA FACADE) **Expected effort**: 1–2 days **Risk**: High — touches every component. Do this last and test end-to-end. @@ -402,17 +361,3 @@ Reorganize `server.py` into FastAPI routers — this is low-risk since Python im **Expected effort**: 2–3 days **Risk**: Low. - ---- - -## General Recommendations - -1. **Extract algorithms from UI**: Several components contain inline algorithms (BFS in `SiteAnalysisResultsPanel`, diffraction in `LinkAnalysisPanel`). Move all pure logic to `src/utils/` or `rf-engine/utils/` — this makes testing significantly easier. - -2. **Introduce a service layer**: API calls are scattered across hooks and components. Centralizing them in `src/services/rfService.js` and `src/services/elevationService.js` would decouple components from fetch logic. - -3. **Colocate tests**: As files are split, add unit tests for extracted utility functions. Pure math functions like those in `rfMath.js` and `rf_physics.py` are ideal first targets. - -4. **Avoid premature abstraction**: Not every file needs splitting. The LOW-priority files (≤225 lines) are generally well-scoped — leave them unless they grow. - -5. **Incremental approach**: Refactor one file at a time. Avoid large "big bang" refactors that make code review difficult and increase regression risk. diff --git a/package-lock.json b/package-lock.json index 3288762..f58f503 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meshrf", - "version": "1.15.3", + "version": "1.15.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meshrf", - "version": "1.15.3", + "version": "1.15.5", "dependencies": { "@deck.gl-community/leaflet": "^9.2.0-beta.3", "@deck.gl/core": "^9.2.5", @@ -134,7 +134,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1748,7 +1747,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1789,7 +1787,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1811,7 +1808,6 @@ "resolved": "https://registry.npmjs.org/@deck.gl/core/-/core-9.2.5.tgz", "integrity": "sha512-/PGNX4Wd7rEahYi6ivC4WExJ3U6Hqgl42R83guNzTL6gM2+02PUQRoQG9QdFagj5d6kWYVN0LVJME2a5WQmzOg==", "license": "MIT", - "peer": true, "dependencies": { "@loaders.gl/core": "^4.2.0", "@loaders.gl/images": "^4.2.0", @@ -1888,7 +1884,6 @@ "resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.2.5.tgz", "integrity": "sha512-a48zWxeHknSX67ZeIzWeLXuOVJkEnQjnLIC27Uv3zHjKlvaoraWPOgScUNyteB0UIIzhzE+D8lF+ViHeIdkNSA==", "license": "MIT", - "peer": true, "dependencies": { "@loaders.gl/images": "^4.2.0", "@loaders.gl/schema": "^4.2.0", @@ -2763,7 +2758,6 @@ "resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.3.4.tgz", "integrity": "sha512-cG0C5fMZ1jyW6WCsf4LoHGvaIAJCEVA/ioqKoYRwoSfXkOf+17KupK1OUQyUCw5XoRn+oWA1FulJQOYlXnb9Gw==", "license": "MIT", - "peer": true, "dependencies": { "@loaders.gl/loader-utils": "4.3.4", "@loaders.gl/schema": "4.3.4", @@ -3071,15 +3065,13 @@ "version": "9.2.5", "resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.2.5.tgz", "integrity": "sha512-Z+DC7LIw+kPcIthGJdSoquIc92kkUlTOINMuJtssPsvOgFYtlsRn29h95K9y8ehjh234q2+73ExjfDQFE64f5Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@luma.gl/core": { "version": "9.2.5", "resolved": "https://registry.npmjs.org/@luma.gl/core/-/core-9.2.5.tgz", "integrity": "sha512-7tQ6wTTtQV6iWZxjMr+BDzS2o814EvaNW5efKtdzdvbbNyDWkDQZkyHkPvgGBB1o/+N2cbZS7GWyxOljCCH5uA==", "license": "MIT", - "peer": true, "dependencies": { "@math.gl/types": "^4.1.0", "@probe.gl/env": "^4.0.8", @@ -3093,7 +3085,6 @@ "resolved": "https://registry.npmjs.org/@luma.gl/engine/-/engine-9.2.5.tgz", "integrity": "sha512-swM+4VO+ab4DU58TB+cdSdby/MGLLWA9ez6BmaZ0HZwLN1HNdYwbQmT71Frtw+6lJpmBZHr78KOQi66Zx5+SOg==", "license": "MIT", - "peer": true, "dependencies": { "@math.gl/core": "^4.1.0", "@math.gl/types": "^4.1.0", @@ -3128,7 +3119,6 @@ "resolved": "https://registry.npmjs.org/@luma.gl/shadertools/-/shadertools-9.2.5.tgz", "integrity": "sha512-9upnT6r5exwotM1atc7Nwa7P69MKx+FavXWlabjk+nrRjeIJwzQ/3sXg9UfdDLavN/cDwGvnWz05UlbyABMmJg==", "license": "MIT", - "peer": true, "dependencies": { "@math.gl/core": "^4.1.0", "@math.gl/types": "^4.1.0", @@ -5891,7 +5881,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6097,7 +6086,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6410,7 +6398,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6563,7 +6550,6 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -7255,7 +7241,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8949,8 +8934,7 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/lerc": { "version": "3.0.0", @@ -9633,7 +9617,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9653,7 +9636,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9870,7 +9852,6 @@ "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -11067,7 +11048,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -11606,7 +11586,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11664,7 +11643,6 @@ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -11956,7 +11934,6 @@ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/components/Layout/Sidebar.jsx b/src/components/Layout/Sidebar.jsx index 1d42137..0084fda 100644 --- a/src/components/Layout/Sidebar.jsx +++ b/src/components/Layout/Sidebar.jsx @@ -1,159 +1,18 @@ import React, { useState, useEffect } from 'react'; -import { RADIO_PRESETS, DEVICE_PRESETS, ANTENNA_PRESETS, CABLE_TYPES } from '../../data/presets'; -import { useRF, GROUND_TYPES, CLIMATE_ZONES } from '../../context/RFContext'; import BatchProcessing from '../Map/BatchProcessing'; - -const CollapsibleSection = ({ title, isOpen, onToggle, children, isShared = false, isITM = false, alwaysVisible = null, collapsible = true }) => ( -
-

-
- {title} - {isShared && (Shared)} - {isITM && (ITM)} -
- {collapsible && ( - - {isOpen ? ( - - - - ) : ( - - - - )} - - )} -

- - {alwaysVisible && ( -
{/* Added margin when closed */} - {alwaysVisible} -
- )} - - {(isOpen || !collapsible) && ( -
- {children} -
- )} -
-); +import HardwareSection from './sections/HardwareSection'; +import EnvironmentSection from './sections/EnvironmentSection'; +import LoRaBandSection from './sections/LoRaBandSection'; +import SettingsSection from './sections/SettingsSection'; +import { useUI } from '../../context/UIContext'; +import { useHardware } from '../../context/HardwareContext'; const Sidebar = () => { const { - selectedRadioPreset, setSelectedRadioPreset, - selectedDevice, setSelectedDevice, - selectedAntenna, setSelectedAntenna, - txPower, setTxPower, - antennaHeight, setAntennaHeight, - antennaGain, setAntennaGain, - selectedCableType, setSelectedCableType, - cableLength, setCableLength, - freq, setFreq, - bw, setBw, - sf, setSf, - cr, setCr, - erp, cableLoss, - units, setUnits, - mapStyle, setMapStyle, - kFactor, setKFactor, - clutterHeight, setClutterHeight, - batchNodes, setBatchNodes, - setShowBatchPanel, - triggerRecalc, - editMode, setEditMode, - rxHeight, setRxHeight, - toolMode, sidebarIsOpen, setSidebarIsOpen, - isMobile, - groundType, setGroundType, - climate, setClimate, - fadeMargin, setFadeMargin, - viewshedMaxDist, setViewshedMaxDist - } = useRF(); - - - - // Initial sync - useEffect(() => { - if (isMobile && sidebarIsOpen) setSidebarIsOpen(false); - }, [isMobile]); // Add dependency - - const isOpen = sidebarIsOpen; - const setIsOpen = setSidebarIsOpen; - - const handleTxPowerChange = (e) => { - setTxPower(Math.min(Number(e.target.value), DEVICE_PRESETS[selectedDevice].tx_power_max)); - }; - - const isCustom = selectedRadioPreset === 'CUSTOM'; - const isCustomAntenna = selectedAntenna === 'CUSTOM'; - - const sectionStyle = { - marginBottom: 'var(--spacing-lg)', - borderBottom: '1px solid var(--color-border)', - paddingBottom: 'var(--spacing-md)' - }; - - const labelStyle = { - display: 'block', - color: 'var(--color-text-muted)', - fontSize: '0.85rem', - marginBottom: 'var(--spacing-xs)', - marginTop: 'var(--spacing-sm)' - }; - - const inputStyle = { - width: '100%', - background: 'rgba(0,0,0,0.3)', - border: '1px solid var(--color-border)', - color: 'var(--color-text-main)', - padding: 'var(--spacing-sm)', - borderRadius: 'var(--radius-md)', - fontFamily: 'monospace' - }; - - const selectStyle = { - ...inputStyle, - cursor: 'pointer', - // Arrow SVG (Cyan Chevron) - backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2300f2ff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E")`, - backgroundRepeat: 'no-repeat', - backgroundPosition: 'right 8px center', - backgroundSize: '16px', - paddingRight: '32px', // Space for arrow - appearance: 'none', - WebkitAppearance: 'none', - MozAppearance: 'none' - }; - - const buttonStyle = { - padding: '8px 16px', - border: 'none', - borderRadius: '4px', - color: '#fff', - fontWeight: 'bold', - cursor: 'pointer', - fontSize: '0.9rem', - marginTop: '8px' - }; + isMobile + } = useUI(); + const { editMode, setEditMode } = useHardware(); const [sections, setSections] = useState({ hardware: true, @@ -165,665 +24,162 @@ const Sidebar = () => { setSections(prev => ({ ...prev, [section]: !prev[section] })); }; + // Auto-close sidebar on mobile + useEffect(() => { + if (isMobile && sidebarIsOpen) setSidebarIsOpen(false); + }, [isMobile]); // Trigger on mount or mobile switch - - - - return ( - <> - - - - - - ); + justifyContent: 'center', + gap: '10px' + }}> + App Icon meshRF + + + {/* EDIT MODE BANNER */} + {editMode !== 'GLOBAL' && ( +
+
+
+ Editing Config +
+
+ {editMode === 'A' ? 'NODE A (TX)' : 'NODE B (RX)'} +
+
+ + +
+ )} + + toggleSection('hardware')} /> + + toggleSection('environment')} /> + + toggleSection('radio')} /> + + {/* BATCH PROCESSING */} +
+ +
+ + + + {/* Footer */} +
+ + + + + d3mocide/MeshRF + +
+ + + ); }; export default Sidebar; diff --git a/src/components/Layout/sections/CollapsibleSection.jsx b/src/components/Layout/sections/CollapsibleSection.jsx new file mode 100644 index 0000000..7f4013b --- /dev/null +++ b/src/components/Layout/sections/CollapsibleSection.jsx @@ -0,0 +1,56 @@ +import React from 'react'; + +const CollapsibleSection = ({ title, isOpen, onToggle, children, isShared = false, isITM = false, alwaysVisible = null, collapsible = true }) => ( +
+

+
+ {title} + {isShared && (Shared)} + {isITM && (ITM)} +
+ {collapsible && ( + + {isOpen ? ( + + + + ) : ( + + + + )} + + )} +

+ + {alwaysVisible && ( +
{/* Added margin when closed */} + {alwaysVisible} +
+ )} + + {(isOpen || !collapsible) && ( +
+ {children} +
+ )} +
+); + +export default CollapsibleSection; diff --git a/src/components/Layout/sections/EnvironmentSection.jsx b/src/components/Layout/sections/EnvironmentSection.jsx new file mode 100644 index 0000000..b5ad4fa --- /dev/null +++ b/src/components/Layout/sections/EnvironmentSection.jsx @@ -0,0 +1,129 @@ +import React from 'react'; +import CollapsibleSection from './CollapsibleSection'; +import { useEnvironment, GROUND_TYPES, CLIMATE_ZONES } from '../../../context/EnvironmentContext'; + +const EnvironmentSection = ({ isOpen, onToggle }) => { + const { + kFactor, setKFactor, + clutterHeight, setClutterHeight, + groundType, setGroundType, + climate, setClimate, + fadeMargin, setFadeMargin + } = useEnvironment(); + + const inputStyle = { + width: '100%', + background: 'rgba(0,0,0,0.3)', + border: '1px solid var(--color-border)', + color: 'var(--color-text-main)', + padding: 'var(--spacing-sm)', + borderRadius: 'var(--radius-md)', + fontFamily: 'monospace' + }; + + const selectStyle = { + ...inputStyle, + cursor: 'pointer', + backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2300f2ff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E")`, + backgroundRepeat: 'no-repeat', + backgroundPosition: 'right 8px center', + backgroundSize: '16px', + paddingRight: '32px', + appearance: 'none', + WebkitAppearance: 'none', + MozAppearance: 'none' + }; + + return ( + +
+
+
+ + setKFactor(parseFloat(e.target.value))} + style={{...inputStyle, padding: '6px', fontSize: '0.9em'}} + /> +
+
+ + setClutterHeight(parseFloat(e.target.value))} + style={{...inputStyle, padding: '6px', fontSize: '0.9em'}} + /> +
+
+ + {/* Ground Type */} +
+ + +
+ + {/* Climate Zone */} +
+ + +
+ + {/* Fade Margin */} +
+ +
+ setFadeMargin(Number(e.target.value))} + style={{ '--range-progress': `${(fadeMargin / 20) * 100}%` }} + /> + + {fadeMargin} +
+
+ +
+
+ ); +}; + +export default EnvironmentSection; diff --git a/src/components/Layout/sections/HardwareSection.jsx b/src/components/Layout/sections/HardwareSection.jsx new file mode 100644 index 0000000..8d771a3 --- /dev/null +++ b/src/components/Layout/sections/HardwareSection.jsx @@ -0,0 +1,299 @@ +import React from 'react'; +import CollapsibleSection from './CollapsibleSection'; +import { useHardware } from '../../../context/HardwareContext'; +import { useUI } from '../../../context/UIContext'; +import { useEnvironment } from '../../../context/EnvironmentContext'; +import { useRadio } from '../../../context/RadioContext'; +import { DEVICE_PRESETS, ANTENNA_PRESETS, CABLE_TYPES } from '../../../data/presets'; + +const HardwareSection = ({ isOpen, onToggle }) => { + const { + editMode, + selectedDevice, setSelectedDevice, + selectedAntenna, setSelectedAntenna, + antennaGain, setAntennaGain, + antennaHeight, setAntennaHeight, + selectedCableType, setSelectedCableType, + cableLength, setCableLength, + txPower, setTxPower, + erp, cableLoss + } = useHardware(); + + const { toolMode, units } = useUI(); + const { rxHeight, setRxHeight } = useEnvironment(); + const { triggerRecalc } = useRadio(); + + const isCustomAntenna = selectedAntenna === 'CUSTOM'; + + const labelStyle = { + display: 'block', + color: 'var(--color-text-muted)', + fontSize: '0.85rem', + marginBottom: 'var(--spacing-xs)', + marginTop: 'var(--spacing-sm)' + }; + + const inputStyle = { + width: '100%', + background: 'rgba(0,0,0,0.3)', + border: '1px solid var(--color-border)', + color: 'var(--color-text-main)', + padding: 'var(--spacing-sm)', + borderRadius: 'var(--radius-md)', + fontFamily: 'monospace' + }; + + const selectStyle = { + ...inputStyle, + cursor: 'pointer', + backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2300f2ff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E")`, + backgroundRepeat: 'no-repeat', + backgroundPosition: 'right 8px center', + backgroundSize: '16px', + paddingRight: '32px', + appearance: 'none', + WebkitAppearance: 'none', + MozAppearance: 'none' + }; + + const handleTxPowerChange = (e) => { + const val = Number(e.target.value); + const max = DEVICE_PRESETS[selectedDevice]?.tx_power_max || 22; + setTxPower(Math.min(val, max)); + }; + + return ( + +
+ + + + + + + + {isCustomAntenna && ( +
+ + setAntennaGain(Number(e.target.value))} + /> +
+ )} + + + setAntennaHeight(Number(e.target.value))} + style={{ + '--range-progress': `${((antennaHeight - 1) / 49) * 100}%`, + '--range-color': '#a855f7' + }} + /> + + {/* RX Height Slider - Only for RF Coverage Tool */} + {toolMode === 'rf_coverage' && ( +
+ + setRxHeight(Number(e.target.value))} + style={{ + '--range-progress': `${((rxHeight - 1) / 29) * 100}%`, + '--range-color': 'var(--color-secondary)' + }} + /> +
+ )} + + {/* CABLE CONFIGURATION */} +
+
+ + +
+
+ + { + const val = Number(e.target.value); + // Store in meters always + setCableLength(units === 'imperial' ? val / 3.28084 : val); + }} + /> +
+
+ + + + + {/* Manual Recalculation Trigger */} + + + {/* ERP CALCULATION DISPLAY */} + +
+
+ ); +}; + +export default HardwareSection; diff --git a/src/components/Layout/sections/LoRaBandSection.jsx b/src/components/Layout/sections/LoRaBandSection.jsx new file mode 100644 index 0000000..9dcd1c9 --- /dev/null +++ b/src/components/Layout/sections/LoRaBandSection.jsx @@ -0,0 +1,126 @@ +import React from 'react'; +import CollapsibleSection from './CollapsibleSection'; +import { useRadio } from '../../../context/RadioContext'; +import { RADIO_PRESETS } from '../../../data/presets'; + +const LoRaBandSection = ({ isOpen, onToggle }) => { + const { + selectedRadioPreset, setSelectedRadioPreset, + freq, setFreq, + bw, setBw, + sf, setSf, + cr, setCr + } = useRadio(); + + const isCustom = selectedRadioPreset === 'CUSTOM'; + + const labelStyle = { + display: 'block', + color: 'var(--color-text-muted)', + fontSize: '0.85rem', + marginBottom: 'var(--spacing-xs)', + marginTop: 'var(--spacing-sm)' + }; + + const inputStyle = { + width: '100%', + background: 'rgba(0,0,0,0.3)', + border: '1px solid var(--color-border)', + color: 'var(--color-text-main)', + padding: 'var(--spacing-sm)', + borderRadius: 'var(--radius-md)', + fontFamily: 'monospace' + }; + + const selectStyle = { + ...inputStyle, + cursor: 'pointer', + backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2300f2ff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E")`, + backgroundRepeat: 'no-repeat', + backgroundPosition: 'right 8px center', + backgroundSize: '16px', + paddingRight: '32px', + appearance: 'none', + WebkitAppearance: 'none', + MozAppearance: 'none' + }; + + const alwaysVisibleContent = ( +
+ + +
+ ); + + return ( + +
+
+ + isCustom && setFreq(e.target.value)} + /> +
+
+ + isCustom && setBw(e.target.value)} + /> +
+
+ + isCustom && setSf(e.target.value)} + /> +
+
+ + isCustom && setCr(e.target.value)} + /> +
+
+
+ ); +}; + +export default LoRaBandSection; diff --git a/src/components/Layout/sections/SettingsSection.jsx b/src/components/Layout/sections/SettingsSection.jsx new file mode 100644 index 0000000..a67d0b6 --- /dev/null +++ b/src/components/Layout/sections/SettingsSection.jsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { useUI } from '../../../context/UIContext'; + +const SettingsSection = () => { + const { units, setUnits, mapStyle, setMapStyle } = useUI(); + + return ( +
+

+ Settings +

+ +
+
+ +
+ + +
+
+ + {/* Map Theme Selector */} +
+ + +
+
+
+ ); +}; + +export default SettingsSection; diff --git a/src/components/Map/MapContainer.jsx b/src/components/Map/MapContainer.jsx index fb8912d..03fae45 100644 --- a/src/components/Map/MapContainer.jsx +++ b/src/components/Map/MapContainer.jsx @@ -1,46 +1,45 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { MapContainer, TileLayer, - ImageOverlay, + ZoomControl, Marker, Popup, - Tooltip, - Polyline, - Rectangle, - Circle, - ZoomControl, + ImageOverlay, + useMap } from "react-leaflet"; import "leaflet/dist/leaflet.css"; import L from "leaflet"; -import LinkLayer from "./LinkLayer"; -import LinkAnalysisPanel from "./LinkAnalysisPanel"; -import OptimizationLayer from "./OptimizationLayer"; -import { useRF, GROUND_TYPES } from "../../context/RFContext"; -import { calculateLinkBudget } from "../../utils/rfMath"; -import { DEVICE_PRESETS } from "../../data/presets"; -import * as turf from "@turf/turf"; -import DeckGLOverlay from "./DeckGLOverlay"; -import WasmViewshedLayer from "./WasmViewshedLayer"; import { ScatterplotLayer } from "@deck.gl/layers"; -import RFCoverageLayer from "./RFCoverageLayer"; +import * as turf from "@turf/turf"; + +// Context & Stores +import { useRF } from "../../context/RFContext"; +import useSimulationStore from "../../store/useSimulationStore"; + +// Hooks +import { useLinkTool } from "./hooks/useLinkTool"; import { useViewshedTool } from "../../hooks/useViewshedTool"; import { useRFCoverageTool } from "../../hooks/useRFCoverageTool"; -import BatchNodesPanel from "./BatchNodesPanel"; -import useSimulationStore from "../../store/useSimulationStore"; +import { useMapEventHandlers } from "./hooks/useMapEventHandlers"; -// Refactored Sub-Components +// Layers & Managers +import DeckGLOverlay from "./DeckGLOverlay"; +import WasmViewshedLayer from "./WasmViewshedLayer"; +import LinkLayerManager from "./layers/LinkLayerManager"; +import ViewshedLayerManager from "./layers/ViewshedLayerManager"; +import CoverageLayerManager from "./layers/CoverageLayerManager"; +import OptimizationLayerManager from "./layers/OptimizationLayerManager"; + +// Controls & UI import LocateControl from "./Controls/LocateControl"; -import CoverageClickHandler from "./Controls/CoverageClickHandler"; -import ViewshedControl from "./Controls/ViewshedControl"; -import BatchNodesPanelWrapper from "./Controls/BatchNodesPanelWrapper"; import MapToolbar from "./UI/MapToolbar"; import GuidanceOverlays from "./UI/GuidanceOverlays"; import SiteAnalysisPanel from "./UI/SiteAnalysisPanel"; import SiteAnalysisResultsPanel from "./UI/SiteAnalysisResultsPanel"; -import OptimizationResultsPanel from "./OptimizationResultsPanel"; +import BatchNodesPanelWrapper from "./Controls/BatchNodesPanelWrapper"; -// Custom SVG marker icon (inline - no loading delay) +// Custom SVG marker icon const customMarkerIcon = L.divIcon({ html: ` @@ -57,119 +56,51 @@ const customMarkerIcon = L.divIcon({ L.Marker.prototype.options.icon = customMarkerIcon; -import { useMapEvents, useMap } from "react-leaflet"; - -// Helper component to capture map clicks -const MultiSiteClickHandler = ({ onLocationSelect }) => { - useMapEvents({ - click(e) { - onLocationSelect({ lat: e.latlng.lat, lng: e.latlng.lng }); - } - }); - return null; -}; - // Helper component to capture map instance const MapInstanceTracker = ({ setMap }) => { const map = useMap(); - React.useEffect(() => { + useEffect(() => { if (map) setMap(map); }, [map, setMap]); return null; }; -// MapComponent const MapComponent = () => { - // Default Map Center (Portland, OR) stabile ref - const defaultLat = 45.5152; - const defaultLng = -122.6784; - const position = React.useMemo(() => [defaultLat, defaultLng], []); - - const { isMobile } = useRF(); - - // Lifted State - const [nodes, setNodes] = useState([]); - const [linkStats, setLinkStats] = useState({ - minClearance: 0, - isObstructed: false, - loading: false, - }); - const [coverageOverlay, setCoverageOverlay] = useState(null); // { url, bounds } - // const [toolMode, setToolMode] = useState('link'); // Lifted to Context - const [viewshedObserver, setViewshedObserver] = useState(null); // Single Point for Viewshed Tool - const [rfObserver, setRfObserver] = useState(null); // Single Point for RF Coverage Tool - const [isLinkLocked, setIsLinkLocked] = useState(false); // Default unlocked - const [viewshedHelp, setViewshedHelp] = useState(false); - const [rfHelp, setRFHelp] = useState(false); - const [linkHelp, setLinkHelp] = useState(false); - const [elevationHelp, setElevationHelp] = useState(false); - const [optimizeState, setOptimizeState] = useState({ - startPoint: null, - endPoint: null, - ghostNodes: [], - loading: false, - }); - const [selectedBatchNodes, setSelectedBatchNodes] = useState([null, null]); // Track selected batch nodes: [TX_node, RX_node] - const [siteAnalysisMode, setSiteAnalysisMode] = useState('auto'); // 'auto' | 'manual' - const [lastClickedLocation, setLastClickedLocation] = useState(null); // click {lat, lng} for manual mode - const [siteSelectionWeights, setSiteSelectionWeights] = useState({ - elevation: 0.5, - prominence: 0.3, - fresnel: 0.2 - }); - const [showAnalysisResults, setShowAnalysisResults] = useState(false); - const [map, setMap] = useState(null); - - // Propagation Model State - const [propagationSettings, setPropagationSettings] = useState({ - model: "itm_wasm", // Default to WASM ITM (most accurate) - environment: "suburban", // Default to Suburban - }); - const selectionRef = React.useRef(0); // Track last selection time to prevent identical double-clicks - - // Calculate Budget at container level for Panel + // 1. Context & Global State const { - toolMode, - setToolMode, - txPower: proxyTx, - antennaGain: proxyGain, - freq, - sf, - cr, - bw, - antennaHeight, - cableLoss, - units, + isMobile, + toolMode, setToolMode, mapStyle, - batchNodes, - showBatchPanel, - setShowBatchPanel, - setBatchNodes, + units, + viewshedMaxDist, setViewshedMaxDist, + batchNodes, setBatchNodes, showBatchPanel, setShowBatchPanel, setEditMode, - nodeConfigs, - recalcTimestamp, - getAntennaHeightMeters, - calculateSensitivity, - rxHeight, - fadeMargin, - groundType, - climate, - viewshedMaxDist, - setViewshedMaxDist, - nodeHeight + nodeConfigs } = useRF(); - // Wasm Viewshed Tool Hook + const { + nodes: simNodes, + results: simResults, + compositeOverlay, + interNodeLinks, + totalUniqueCoverageKm2 + } = useSimulationStore(); + + // 2. Local State + const [map, setMap] = useState(null); + + // Viewshed State + const [viewshedObserver, setViewshedObserver] = useState(null); const { runAnalysis: runViewshedAnalysis, resultLayer: viewshedLayer, isCalculating: isViewshedCalculating, progress: viewshedProgress, - error: viewshedError, clear: clearViewshed } = useViewshedTool(toolMode === 'viewshed'); - // RF Coverage Tool Hook + // RF Coverage State + const [rfObserver, setRfObserver] = useState(null); const { runAnalysis: runRFAnalysis, resultLayer: rfResultLayer, @@ -177,135 +108,58 @@ const MapComponent = () => { clear: clearRFCoverage, } = useRFCoverageTool(toolMode === "rf_coverage"); - // Verify sensitivity or default to a reasonable LoRa value - const sensitivity = calculateSensitivity ? calculateSensitivity() : -126; // Default SF7/BW125 - // Map Configs - const MAP_STYLES = { - dark: { - url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", - attribution: - '© OpenStreetMap contributors © CARTO', - }, - dark_green: { - // Use Light map (Voyager) + CSS Filter to get "Dark with Colors" (Green Parks) - url: "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", - attribution: - '© OpenStreetMap contributors © CARTO', - className: "dark-mode-tiles", - }, - light: { - url: "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", - attribution: - '© OpenStreetMap contributors © CARTO', - }, - topo: { - url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", - attribution: - "Tiles © Esri — Esri, DeLorme, NAVTEQ, TomTom, Intermap, iPC, USGS, FAO, NPS, NRCAN, GeoBase, Kadaster NL, Ordnance Survey, Esri Japan, METI, Esri China (Hong Kong), and the GIS User Community", - }, - topo_dark: { - url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", - attribution: - "Tiles © Esri — Esri, DeLorme, NAVTEQ, TomTom, Intermap, iPC, USGS, FAO, NPS, NRCAN, GeoBase, Kadaster NL, Ordnance Survey, Esri Japan, METI, Esri China (Hong Kong), and the GIS User Community", - className: "dark-mode-tiles", - }, - satellite: { - url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", - attribution: - "Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community", - }, - }; + // Optimization State + const [optimizeState, setOptimizeState] = useState({ + startPoint: null, + endPoint: null, + ghostNodes: [], + loading: false, + }); + const [siteAnalysisMode, setSiteAnalysisMode] = useState('auto'); + const [lastClickedLocation, setLastClickedLocation] = useState(null); + const [siteSelectionWeights, setSiteSelectionWeights] = useState({ + elevation: 0.5, + prominence: 0.3, + fresnel: 0.2 + }); + const [showAnalysisResults, setShowAnalysisResults] = useState(false); - const currentStyle = MAP_STYLES[mapStyle] || MAP_STYLES.dark_green; + // Link Tool Hook + const { + nodes, setNodes, + linkStats, setLinkStats, + coverageOverlay, setCoverageOverlay, + isLinkLocked, setIsLinkLocked, + selectedBatchNodes, setSelectedBatchNodes, + propagationSettings, setPropagationSettings, + budget, distance, + handleNodeSelect, + reset: resetLinkTool + } = useLinkTool(); + + // Guidance Help State + const [viewshedHelp, setViewshedHelp] = useState(false); + const [rfHelp, setRFHelp] = useState(false); + const [linkHelp, setLinkHelp] = useState(false); + const [elevationHelp, setElevationHelp] = useState(false); + // 3. Effects & handlers - // Trigger RF Recalculation on Parameter Change (via 'Update Calculation' button) + // Auto-show results useEffect(() => { - if (recalcTimestamp && toolMode === "rf_coverage" && rfObserver) { - const { lat, lng } = rfObserver; - console.log("Triggering RF Recalculation due to param update"); - - // Recalculate height from current context (in case user changed height/units) - const currentHeight = getAntennaHeightMeters - ? getAntennaHeightMeters() - : rfObserver.height; - - // Recalculate sensitivity - const currentSensitivity = calculateSensitivity - ? calculateSensitivity() - : -126; - - const ground = GROUND_TYPES[groundType] || GROUND_TYPES['Average Ground']; - const rfParams = { - freq, - txPower: proxyTx, - txGain: proxyGain, - txLoss: cableLoss, - rxLoss: 0, - rxGain: nodeConfigs.B.antennaGain || 2.15, - rxSensitivity: currentSensitivity, - bw, - sf, - cr, - rxHeight, - epsilon: ground.epsilon, - sigma: ground.sigma, - climate: climate, - }; - console.log( - `[RF Recalc] Height: ${currentHeight.toFixed(2)}m, Params:`, - rfParams, - ); - runRFAnalysis(lat, lng, currentHeight, 25000, rfParams); + if (simResults && simResults.length > 0) { + setShowAnalysisResults(true); } - }, [recalcTimestamp]); - - let budget = null; - let distance = 0; - - if (nodes.length === 2) { - const [p1, p2] = nodes; - distance = turf.distance([p1.lng, p1.lat], [p2.lng, p2.lat], { - units: "kilometers", - }); - - // Determine Path Loss logic - const configA = nodeConfigs.A; - const configB = nodeConfigs.B; - - // Use backend path loss if available (calculated by LinkLayer), otherwise null (FSPL) - let pathLossVal = linkStats.backendPathLoss || null; - - budget = calculateLinkBudget({ - txPower: configA.txPower, - txGain: configA.antennaGain, - txLoss: DEVICE_PRESETS[configA.device]?.loss || 0, - rxGain: configB.antennaGain, - rxLoss: DEVICE_PRESETS[configB.device]?.loss || 0, - distanceKm: distance, - freqMHz: freq, - sf, - bw, - pathLossOverride: pathLossVal, - fadeMargin - }); - } + }, [simResults]); - // Helper to reset all tool states (Clear View) const resetToolState = () => { - setNodes([]); - setIsLinkLocked(false); - setLinkStats({ minClearance: 0, isObstructed: false, loading: false }); - setCoverageOverlay(null); + resetLinkTool(); setViewshedObserver(null); setRfObserver(null); - setSelectedBatchNodes([null, null]); // Reset to initial state - setEditMode("GLOBAL"); // Clear node editing state - - // Clear Site Analysis states + setLastClickedLocation(null); useSimulationStore.getState().reset(); setShowAnalysisResults(false); - setLastClickedLocation(null); + setEditMode("GLOBAL"); }; const handleOptimizationStateUpdate = React.useCallback((state) => { @@ -315,199 +169,113 @@ const MapComponent = () => { } }, []); - const handleNodeSelect = (node, isBatch = false) => { - // Only allow selection in link mode - if (toolMode !== "link" || isLinkLocked) return; - - // Temporal guard: Ignore calls within 100ms (prevents double-activation from event bubbling) - const now = Date.now(); - if (now - selectionRef.current < 100) return; - selectionRef.current = now; - - // Use sequential updates (React will batch these) - const isNewLink = nodes.length === 0 || nodes.length >= 2; - const nodeData = { - lat: node.lat, - lng: node.lng, - isBatch, - batchId: isBatch ? node.id : null, - }; - - if (isNewLink) { - setNodes([nodeData]); - setEditMode("A"); - setSelectedBatchNodes([ - isBatch - ? { id: node.id, name: node.name, role: "TX" } - : { id: "manual-tx", role: "TX" }, - null, - ]); - } else { - setNodes((prev) => [...prev, nodeData]); - setEditMode("B"); - setSelectedBatchNodes((prev) => [ - prev[0], - isBatch - ? { id: node.id, name: node.name, role: "RX" } - : { id: "manual-rx", role: "RX" }, - ]); - } + // Map Configs + const MAP_STYLES = { + dark: { url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", attribution: '© OpenStreetMap contributors © CARTO' }, + dark_green: { url: "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", attribution: '© OpenStreetMap contributors © CARTO', className: "dark-mode-tiles" }, + light: { url: "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", attribution: '© OpenStreetMap contributors © CARTO' }, + topo: { url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", attribution: "Tiles © Esri — Esri, DeLorme, NAVTEQ, TomTom, Intermap, iPC, USGS, FAO, NPS, NRCAN, GeoBase, Kadaster NL, Ordnance Survey, Esri Japan, METI, Esri China (Hong Kong), and the GIS User Community" }, + topo_dark: { url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", attribution: "Tiles © Esri — Esri, DeLorme, NAVTEQ, TomTom, Intermap, iPC, USGS, FAO, NPS, NRCAN, GeoBase, Kadaster NL, Ordnance Survey, Esri Japan, METI, Esri China (Hong Kong), and the GIS User Community", className: "dark-mode-tiles" }, + satellite: { url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", attribution: "Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community" }, }; + const currentStyle = MAP_STYLES[mapStyle] || MAP_STYLES.dark_green; + // DeckGL Layers Preparation + const deckLayers = useMemo(() => { + const layers = []; + + // Viewshed + if (toolMode === "viewshed" && viewshedLayer?.data) { + const { width, height, data, bounds } = viewshedLayer; + const rgbaData = new Uint8ClampedArray(width * height * 4); + for (let i = 0; i < width * height; i++) { + const val = data[i]; + rgbaData[i * 4] = val; + rgbaData[i * 4 + 1] = 0; + rgbaData[i * 4 + 2] = 0; + rgbaData[i * 4 + 3] = 255; + } + layers.push( + new WasmViewshedLayer({ + id: "wasm-viewshed-layer-stitched", + image: new ImageData(rgbaData, width, height), + bounds: [bounds.west, bounds.south, bounds.east, bounds.north], + opacity: 0.6, + showShadows: false, + observer: viewshedLayer.observerCoords + ? [viewshedLayer.observerCoords.x / width, 1.0 - (viewshedLayer.observerCoords.y / height)] + : [0.5, 0.5], + radius: viewshedLayer.radiusPixels ? (viewshedLayer.radiusPixels / width) : 0.0, + }), + ); + } + + // RF Coverage + if (toolMode === "rf_coverage" && rfResultLayer?.data) { + const { width, height, data, rfParams, bounds } = rfResultLayer; + const { west, south, east, north } = bounds; + const points = []; + const latStep = (north - south) / height; + const lonStep = (east - west) / width; + const bwHz = (rfParams?.bw || 125) * 1000; + const noiseFloor = -174 + 10 * Math.log10(bwHz); + const sensitivity = rfParams?.rxSensitivity || -120; + const NO_DATA = -999.0; + + for (let i = 0; i < data.length; i++) { + const rssi = data[i]; + const isBackground = rssi <= NO_DATA + 1; + if (isBackground) continue; + + const y = Math.floor(i / width); + const x = i % width; + const pLat = north - (y + 0.5) * latStep; + const pLon = west + (x + 0.5) * lonStep; + + points.push({ + position: [pLon, pLat], + rssi, + snr: rssi - noiseFloor, + isBackground, + }); + } - // Simulation Store integration - const { nodes: simNodes, results: simResults, compositeOverlay, interNodeLinks, totalUniqueCoverageKm2 } = useSimulationStore(); - - // Automatically show results panel when scan finishes - useEffect(() => { - if (simResults && simResults.length > 0) { - setShowAnalysisResults(true); - } - }, [simResults]); - - // Prepare DeckGL Layers - const deckLayers = []; - - - // Viewshed Layer (Only active in 'viewshed' mode) - if (toolMode === "viewshed" && viewshedLayer && viewshedLayer.data && viewshedLayer.width && viewshedLayer.height) { - // viewshedLayer is the single stitched viewshed from WASM worker - const { width, height, data, bounds } = viewshedLayer; - - // Convert single-channel data to RGBA for BitmapLayer - // The WasmViewshedLayer shader will apply purple coloring - const rgbaData = new Uint8ClampedArray(width * height * 4); - for (let i = 0; i < width * height; i++) { - const val = data[i]; - rgbaData[i * 4] = val; // R channel contains visibility data - rgbaData[i * 4 + 1] = 0; // G - rgbaData[i * 4 + 2] = 0; // B - rgbaData[i * 4 + 3] = 255; // A - } - const imageData = new ImageData(rgbaData, width, height); - - deckLayers.push( - new WasmViewshedLayer({ - id: "wasm-viewshed-layer-stitched", - image: imageData, - bounds: [bounds.west, bounds.south, bounds.east, bounds.north], - opacity: 0.6, - showShadows: false, - // Pass normalized observer and radius for shader masking (Bug 2) - // FLIP Y: Texture coords are 0..1 (bottom-up in some contexts, top-down in others). - // If half is missing, it's likely a flip. - // Let's try flipping Y: (1.0 - y) - observer: viewshedLayer.observerCoords - ? [viewshedLayer.observerCoords.x / width, 1.0 - (viewshedLayer.observerCoords.y / height)] - : [0.5, 0.5], - radius: viewshedLayer.radiusPixels - ? (viewshedLayer.radiusPixels / width) - : 0.0, - }), - ); - } - - // Viewshed Debug Visuals - // 1. Configured Radius Circle (Cyan) - Useful for user to see max range - let debugRadiusCircle = null; - if (toolMode === "viewshed" && viewshedObserver && viewshedMaxDist) { - debugRadiusCircle = { - center: viewshedObserver, - radius: viewshedMaxDist - }; - } - - // RF Coverage Layer (Only active in 'rf_coverage' mode) - let rfBounds = null; - if (toolMode === "rf_coverage" && rfResultLayer && rfResultLayer.data) { - const { width, height, data, rfParams, bounds } = rfResultLayer; - - console.log( - `[MapContainer] Processing ${data.length} pixels for RF visualization`, - ); - - console.log( - `[MapContainer] Processing ${data.length} pixels for RF visualization`, - ); - - const { west, south, east, north } = bounds; - - // Generate points for Scatterplot visualization - const points = []; - - // Calculate step sizes in degrees - const latStep = (north - south) / height; - const lonStep = (east - west) / width; - - // Noise floor for SNR calc - const bwHz = (rfParams?.bw || 125) * 1000; - const noiseFloor = -174 + 10 * Math.log10(bwHz); - const sensitivity = rfParams?.rxSensitivity || -120; - - const NO_DATA = -999.0; - - // Iterate ALL pixels - for (let i = 0; i < data.length; i++) { - const rssi = data[i]; // Raw dBm value - - let snr = -999; - - // Separate valid signals from background - const isBackground = rssi <= NO_DATA + 1; - - // Skip background/no-data pixels (User requested to drop the grid) - if (isBackground) continue; - - snr = rssi - noiseFloor; - - // Calculate x, y from index - const y = Math.floor(i / width); - const x = i % width; - - // Calculate Lat/Lon - const pLat = north - (y + 0.5) * latStep; - const pLon = west + (x + 0.5) * lonStep; - - points.push({ - position: [pLon, pLat], - rssi, - snr, - isBackground, - }); - } - - deckLayers.push( - new ScatterplotLayer({ - id: "rf-coverage-dots", - data: points, - pickable: true, - opacity: 0.6, - stroked: false, - filled: true, - radiusScale: 1, - radiusMinPixels: 2, - radiusMaxPixels: 6, - getPosition: (d) => d.position, - getFillColor: (d) => { - if (d.isBackground) return [30, 30, 40, 40]; // Faint dark grid for background - - // Color based on SNR/RSSI - const relativeStrength = d.rssi - sensitivity; - - if (relativeStrength > 20) return [0, 255, 65, 200]; // Excellent (>20dB margin) - if (relativeStrength > 10) return [100, 255, 0, 200]; // Good (>10dB margin) - if (relativeStrength > 5) return [255, 255, 0, 200]; // Fair (>5dB margin) - if (relativeStrength > 0) return [255, 120, 0, 180]; // Marginal (0-5dB margin) - return [100, 0, 255, 120]; // Very Weak (Near floor) - Purple - }, - }), - ); - } + layers.push( + new ScatterplotLayer({ + id: "rf-coverage-dots", + data: points, + pickable: true, + opacity: 0.6, + stroked: false, + filled: true, + radiusScale: 1, + radiusMinPixels: 2, + radiusMaxPixels: 6, + getPosition: (d) => d.position, + getFillColor: (d) => { + const relativeStrength = d.rssi - sensitivity; + if (relativeStrength > 20) return [0, 255, 65, 200]; + if (relativeStrength > 10) return [100, 255, 0, 200]; + if (relativeStrength > 5) return [255, 255, 0, 200]; + if (relativeStrength > 0) return [255, 120, 0, 180]; + return [100, 0, 255, 120]; + }, + }), + ); + } + return layers; + }, [toolMode, viewshedLayer, rfResultLayer]); + + + const defaultPosition = [45.5152, -122.6784]; + + // Pass RF context explicitly to handler to avoid stale closures in event loop + const rfContextFacade = useRF(); return (
{ - + - { - // When user clicks map manually, we treat it as a non-batch node selection - handleNodeSelect({ lat: e.latlng.lat, lng: e.latlng.lng }, false); - }} + handleNodeSelect({ lat: e.latlng.lat, lng: e.latlng.lng }, false)} /> - {coverageOverlay && ( - - )} - - {/* Multi-Site Composite Overlay */} - {compositeOverlay && compositeOverlay.bounds && ( - - )} - {/* Visual Marker for Viewshed Observer */} - {toolMode === "viewshed" && viewshedObserver && ( - { - const { lat, lng } = e.target.getLatLng(); - // FIX BUG 3: Preserve antenna height on drag - const currentHeight = viewshedObserver?.height || 2.0; - setViewshedObserver({ lat, lng, height: currentHeight }); - runViewshedAnalysis({ lat, lng, height: currentHeight }, viewshedMaxDist); - }, - }} - > - Viewshed Transmitter - - )} - - {/* Visual Marker for RF Coverage Transmitter */} - {toolMode === "rf_coverage" && rfObserver && ( - { - const { lat, lng } = e.target.getLatLng(); - - // Update position and recalculate - fetch("/api/get-elevation", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ lat, lon: lng }), - }) - .then((res) => res.json()) - .then((data) => { - const elevation = data.elevation || 0; - const h = antennaHeight || 5.0; // Keep relative height from ground - - setRfObserver({ lat, lng, height: h }); - - const currentSensitivity = calculateSensitivity - ? calculateSensitivity() - : -126; - const dragGround = GROUND_TYPES[groundType] || GROUND_TYPES['Average Ground']; - const rfParams = { - freq, - txPower: proxyTx, - txGain: proxyGain, - txLoss: cableLoss, - rxLoss: 0, - rxGain: nodeConfigs.B.antennaGain || 2.15, - rxSensitivity: currentSensitivity, - bw, - sf, - cr, - rxHeight, - epsilon: dragGround.epsilon, - sigma: dragGround.sigma, - climate: climate, - }; - - runRFAnalysis(lat, lng, h, 25000, rfParams); - }); - }, - }} - > - RF Transmitter - - )} - - - - {/* Debug: Viewshed Radius (Cyan Dashed) */} - {debugRadiusCircle && ( - - )} - - {/* Viewshed Floating Control */} - {toolMode === 'viewshed' && ( - { - if (viewshedObserver) { - runViewshedAnalysis(viewshedObserver, viewshedMaxDist); - } - }} + maxDist={viewshedMaxDist} setMaxDist={setViewshedMaxDist} + clear={clearViewshed} isMobile={isMobile} /> - )} - {/* RF Coverage Bounds Rectangle */} - {rfBounds && ( - - )} - - {/* Multi-Site Click Handler */} - {toolMode === 'optimize' && siteAnalysisMode === 'manual' && ( - { - setLastClickedLocation(loc); - // Proactive addition: Add node to store directly on click - useSimulationStore.getState().addNode({ - lat: loc.lat, - lon: loc.lng, - height: 10, - name: `Node ${simNodes.length + 1}` - }); - }} - /> - )} + - setToolMode(active ? "optimize" : "none"), - [setToolMode], - )} - onStateUpdate={handleOptimizationStateUpdate} - weights={siteSelectionWeights} + setToolMode(active ? "optimize" : "none")} + siteAnalysisMode={siteAnalysisMode} + lastClickedLocation={lastClickedLocation} + setLastClickedLocation={setLastClickedLocation} + onStateUpdate={handleOptimizationStateUpdate} + weights={siteSelectionWeights} + simNodes={simNodes} + simResults={simResults} + interNodeLinks={interNodeLinks} + compositeOverlay={compositeOverlay} + units={units} /> - - {/* SiteAnalysisPanel moved outside to prevent click-through */} {/* Batch Nodes Rendering */} - {batchNodes.length > 0 && - batchNodes.map((node) => { - // Check if this node is selected by looking at indices 0 and 1 - const selectionTX = selectedBatchNodes[0]; - const selectionRX = selectedBatchNodes[1]; - const isTX = selectionTX?.id === node.id; - const isRX = selectionRX?.id === node.id; + {batchNodes.length > 0 && batchNodes.map((node) => { + const isTX = selectedBatchNodes[0]?.id === node.id; + const isRX = selectedBatchNodes[1]?.id === node.id; const isSelected = isTX || isRX; - const role = isTX ? "TX" : isRX ? "RX" : null; - // Determine styling based on selection let className = "batch-node-icon"; let bgColor = "#00f2ff"; let boxShadow = "0 0 8px rgba(0, 242, 255, 0.6)"; if (isSelected) { - if (role === "TX") { - // Don't add animation class - it causes ghost elements - bgColor = "#00ff41"; - boxShadow = "0 0 12px rgba(0, 255, 65, 0.8)"; - } else if (role === "RX") { - // Don't add animation class - it causes ghost elements - bgColor = "#ff0000"; - boxShadow = "0 0 12px rgba(255, 0, 0, 0.8)"; - } + if (isTX) { bgColor = "#00ff41"; boxShadow = "0 0 12px rgba(0, 255, 65, 0.8)"; } + else if (isRX) { bgColor = "#ff0000"; boxShadow = "0 0 12px rgba(255, 0, 0, 0.8)"; } } return ( @@ -769,103 +384,9 @@ const MapComponent = () => { {node.name} ); - })} - - {/* Temporary Node Marker (Before "Add" is clicked) */} - {toolMode === 'optimize' && siteAnalysisMode === 'manual' && lastClickedLocation && ( -
`, - iconSize: [16, 16], - iconAnchor: [8, 8], - })} - > - New Site Candidate - - )} - - {/* Simulation Nodes Rendering (Multi-Site Analysis) */} - {toolMode === 'optimize' && siteAnalysisMode === 'manual' && simNodes.map((node) => ( - ${simResults ? '✓' : ''}`, - iconSize: [14, 14], - iconAnchor: [7, 7], - })} - > - - {node.name}
- Lat: {node.lat.toFixed(5)}
- Lon: {node.lon.toFixed(5)}
- {simResults && Array.isArray(simResults) && (() => { - const res = simResults.find(r => Math.abs(r.lat - node.lat) < 0.0001 && Math.abs(r.lon - node.lon) < 0.0001); - if (!res) return null; - return ( -
-
- Elevation: - - {units === 'imperial' - ? `${(res.elevation * 3.28084).toFixed(1)} ft` - : `${res.elevation} m`} - -
-
- Coverage: - - {units === 'imperial' - ? `${(res.coverage_area_km2 * 0.386102).toFixed(2)} mi²` - : `${res.coverage_area_km2} km²`} - -
-
- ({res.coverage_points} visible points) -
-
- ); - })()} -
-
- ))} - - {/* Inter-node link quality polylines */} - {showAnalysisResults && simResults && interNodeLinks && interNodeLinks.map((link, i) => { - const nodeA = simResults[link.node_a_idx]; - const nodeB = simResults[link.node_b_idx]; - if (!nodeA || !nodeB) return null; - const colorMap = { viable: '#00f2ff', degraded: '#ffd700', blocked: '#ff4444', unknown: '#888' }; - const color = colorMap[link.status] || '#888'; - const dashArray = link.status === 'blocked' ? '6 6' : link.status === 'degraded' ? '10 4' : null; - return ( - - ); })} - {/* Batch Nodes Panel - Must be inside MapContainer to use useMap hook */} + {/* Batch Nodes Panel */} {showBatchPanel && batchNodes.length > 0 && ( { onClear={() => { setBatchNodes([]); setShowBatchPanel(false); - resetToolState(); // Reset active link/markers when clearing batch panel + resetToolState(); }} onNodeSelect={(node) => handleNodeSelect(node, true)} forceMinimized={isMobile && nodes.length === 2} /> )} + { selectedLocation={lastClickedLocation} /> - {/* Tool Toggles */} - { isMobile={isMobile} viewshedObserver={viewshedObserver} rfObserver={rfObserver} - linkHelp={linkHelp} - setLinkHelp={setLinkHelp} - elevationHelp={elevationHelp} - setElevationHelp={setElevationHelp} - viewshedHelp={viewshedHelp} - setViewshedHelp={setViewshedHelp} - rfHelp={rfHelp} - setRFHelp={setRFHelp} - /> + linkHelp={linkHelp} setLinkHelp={setLinkHelp} + elevationHelp={elevationHelp} setElevationHelp={setElevationHelp} + viewshedHelp={viewshedHelp} setViewshedHelp={setViewshedHelp} + rfHelp={rfHelp} setRFHelp={setRFHelp} + /> - {/* Clear Link Button - Shows when link nodes exist */} + {/* Clear Link Button */} {nodes.length > 0 && ( -
+
)} - {/* Clear Viewshed Button */} - {toolMode === "viewshed" && viewshedObserver && ( -
- -
- )} - - {/* Clear RF Coverage Button */} - {toolMode === "rf_coverage" && rfObserver && ( -
- -
- )} - - {/* Overlay Panel */} - {nodes.length === 2 && ( - - )} - - {/* Site Analysis Results Panel moved outside to prevent click-through */} + {/* Site Analysis Results Panel */} {showAnalysisResults && simResults && simResults.length > 0 && ( { totalUniqueCoverageKm2={totalUniqueCoverageKm2} units={units} onCenter={(res) => { - if (map) { - map.flyTo([res.lat, res.lon], 15); - } + if (map) map.flyTo([res.lat, res.lon], 15); }} onClear={() => { setShowAnalysisResults(false); - // Fully reset store (nodes, results, overlay) useSimulationStore.getState().reset(); }} onRunNew={() => { @@ -1109,65 +507,14 @@ const MapComponent = () => { /> )} - {/* RF Coverage Loading Status */} - {isRFCalculating && ( -
-
- -
- CALCULATING RF COVERAGE -
-
- Running ITM propagation model... -
-
- )}
); }; +// Wrapper to allow hook usage inside MapContainer +const HandlerWrapper = (props) => { + useMapEventHandlers(props); + return null; +} + export default MapComponent; diff --git a/src/components/Map/hooks/useLinkTool.js b/src/components/Map/hooks/useLinkTool.js new file mode 100644 index 0000000..f5253ea --- /dev/null +++ b/src/components/Map/hooks/useLinkTool.js @@ -0,0 +1,119 @@ +import { useState, useRef } from 'react'; +import { useRF } from '../../../context/RFContext'; +import { calculateLinkBudget } from '../../../utils/rfMath'; +import { DEVICE_PRESETS } from '../../../data/presets'; +import * as turf from '@turf/turf'; + +export const useLinkTool = () => { + const { + nodeConfigs, + freq, sf, bw, fadeMargin, + setEditMode + } = useRF(); + + const [nodes, setNodes] = useState([]); + const [linkStats, setLinkStats] = useState({ + minClearance: 0, + isObstructed: false, + loading: false, + }); + const [coverageOverlay, setCoverageOverlay] = useState(null); + const [isLinkLocked, setIsLinkLocked] = useState(false); + const [selectedBatchNodes, setSelectedBatchNodes] = useState([null, null]); // [TX, RX] + + // Propagation Model State (Local to Link Tool) + const [propagationSettings, setPropagationSettings] = useState({ + model: "itm_wasm", + environment: "suburban", + }); + + const selectionRef = useRef(0); + + // Calculate Budget & Distance + let budget = null; + let distance = 0; + + if (nodes.length === 2) { + const [p1, p2] = nodes; + distance = turf.distance([p1.lng, p1.lat], [p2.lng, p2.lat], { + units: "kilometers", + }); + + // Determine Path Loss logic + const configA = nodeConfigs.A; + const configB = nodeConfigs.B; + + let pathLossVal = linkStats.backendPathLoss || null; + + budget = calculateLinkBudget({ + txPower: configA.txPower, + txGain: configA.antennaGain, + txLoss: DEVICE_PRESETS[configA.device]?.loss || 0, + rxGain: configB.antennaGain, + rxLoss: DEVICE_PRESETS[configB.device]?.loss || 0, + distanceKm: distance, + freqMHz: freq, + sf, + bw, + pathLossOverride: pathLossVal, + fadeMargin + }); + } + + const handleNodeSelect = (node, isBatch = false) => { + // Temporal guard + const now = Date.now(); + if (now - selectionRef.current < 100) return; + selectionRef.current = now; + + const isNewLink = nodes.length === 0 || nodes.length >= 2; + const nodeData = { + lat: node.lat, + lng: node.lng, + isBatch, + batchId: isBatch ? node.id : null, + }; + + if (isNewLink) { + setNodes([nodeData]); + setEditMode("A"); + setSelectedBatchNodes([ + isBatch + ? { id: node.id, name: node.name, role: "TX" } + : { id: "manual-tx", role: "TX" }, + null, + ]); + } else { + setNodes((prev) => [...prev, nodeData]); + setEditMode("B"); + setSelectedBatchNodes((prev) => [ + prev[0], + isBatch + ? { id: node.id, name: node.name, role: "RX" } + : { id: "manual-rx", role: "RX" }, + ]); + } + }; + + const reset = () => { + setNodes([]); + setIsLinkLocked(false); + setLinkStats({ minClearance: 0, isObstructed: false, loading: false }); + setCoverageOverlay(null); + setSelectedBatchNodes([null, null]); + setEditMode("GLOBAL"); + }; + + return { + nodes, setNodes, + linkStats, setLinkStats, + coverageOverlay, setCoverageOverlay, + isLinkLocked, setIsLinkLocked, + selectedBatchNodes, setSelectedBatchNodes, + propagationSettings, setPropagationSettings, + budget, + distance, + handleNodeSelect, + reset + }; +}; diff --git a/src/components/Map/hooks/useMapEventHandlers.js b/src/components/Map/hooks/useMapEventHandlers.js new file mode 100644 index 0000000..a8ac1a2 --- /dev/null +++ b/src/components/Map/hooks/useMapEventHandlers.js @@ -0,0 +1,49 @@ +import { useMapEvents } from 'react-leaflet'; +import { GROUND_TYPES } from '../../../context/EnvironmentContext'; + +export const useMapEventHandlers = ({ + toolMode, + viewshed: { runAnalysis: runViewshed, setObserver: setViewshedObserver, maxDist: viewshedMaxDist }, + rfCoverage: { runAnalysis: runRF, setObserver: setRfObserver }, + rfContext // Contains height, freq, etc. from useRF facade +}) => { + useMapEvents({ + click(e) { + if (toolMode === 'viewshed' || toolMode === 'rf_coverage') { + const { lat, lng } = e.latlng; + + // Common Height Logic + const h = rfContext.getAntennaHeightMeters ? rfContext.getAntennaHeightMeters() : (rfContext.antennaHeight || 2.0); + const dist = viewshedMaxDist || 25000; + + if (toolMode === 'viewshed') { + setViewshedObserver({ lat, lng, height: h }); + runViewshed(lat, lng, h, dist); + } else if (toolMode === 'rf_coverage') { + setRfObserver({ lat, lng, height: h }); + + const ground = GROUND_TYPES[rfContext.groundType] || GROUND_TYPES['Average Ground']; + + const rfParams = { + freq: rfContext.freq, + txPower: rfContext.txPower, + txGain: rfContext.antennaGain, + txLoss: rfContext.cableLoss || 0, + rxLoss: 0, + rxGain: rfContext.nodeConfigs.B.antennaGain || 2.15, + rxSensitivity: rfContext.calculateSensitivity ? rfContext.calculateSensitivity() : -126, + bw: rfContext.bw, + sf: rfContext.sf, + cr: rfContext.cr, + rxHeight: rfContext.rxHeight, + epsilon: ground.epsilon, + sigma: ground.sigma, + climate: rfContext.climate + }; + + runRF(lat, lng, h, dist, rfParams); + } + } + } + }); +}; diff --git a/src/components/Map/layers/CoverageLayerManager.jsx b/src/components/Map/layers/CoverageLayerManager.jsx new file mode 100644 index 0000000..e1c9fb6 --- /dev/null +++ b/src/components/Map/layers/CoverageLayerManager.jsx @@ -0,0 +1,215 @@ +import React, { useEffect } from 'react'; +import { Marker, Popup, Rectangle } from 'react-leaflet'; +import { useRF } from '../../../context/RFContext'; +import { GROUND_TYPES } from '../../../context/EnvironmentContext'; + +const CoverageLayerManager = ({ + active, + observer, setObserver, + runAnalysis, + isCalculating, + clear, + bounds +}) => { + const { + freq, + txPower, + antennaGain, + cableLoss, + nodeConfigs, + calculateSensitivity, + bw, sf, cr, + rxHeight, + groundType, + climate, + antennaHeight, + recalcTimestamp // Recalc signal + } = useRF(); + + // Trigger RF Recalculation on Parameter Change + useEffect(() => { + if (recalcTimestamp && active && observer) { + const { lat, lng } = observer; + // Use current context height + const h = antennaHeight || 5.0; + const currentSensitivity = calculateSensitivity(); + const ground = GROUND_TYPES[groundType] || GROUND_TYPES['Average Ground']; + + const rfParams = { + freq, + txPower, + txGain: antennaGain, + txLoss: cableLoss, + rxLoss: 0, + rxGain: nodeConfigs.B.antennaGain || 2.15, + rxSensitivity: currentSensitivity, + bw, sf, cr, + rxHeight, + epsilon: ground.epsilon, + sigma: ground.sigma, + climate: climate, + }; + + runAnalysis(lat, lng, h, 25000, rfParams); + } + }, [recalcTimestamp]); + + if (!active) return null; + + const buttonStyle = { + background: "rgba(255, 50, 50, 0.9)", + color: "#fff", + border: "1px solid rgba(255, 100, 100, 0.5)", + padding: "0 12px", + height: "36px", + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: "4px", + cursor: "pointer", + fontWeight: "bold", + fontSize: "14px", + boxShadow: "0 2px 5px rgba(0,0,0,0.5)", + transition: "all 0.2s ease", + }; + + return ( + <> + {observer && ( + { + const { lat, lng } = e.target.getLatLng(); + + // Update position and recalculate + fetch("/api/get-elevation", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lat, lon: lng }), + }) + .then((res) => res.json()) + .then((data) => { + const elevation = data.elevation || 0; + const h = antennaHeight || 5.0; // Keep relative height from ground + + setObserver({ lat, lng, height: h }); + + const currentSensitivity = calculateSensitivity(); + const dragGround = GROUND_TYPES[groundType] || GROUND_TYPES['Average Ground']; + + const rfParams = { + freq, + txPower, + txGain: antennaGain, + txLoss: cableLoss, + rxLoss: 0, + rxGain: nodeConfigs.B.antennaGain || 2.15, + rxSensitivity: currentSensitivity, + bw, sf, cr, + rxHeight, + epsilon: dragGround.epsilon, + sigma: dragGround.sigma, + climate: climate, + }; + + runAnalysis(lat, lng, h, 25000, rfParams); + }); + }, + }} + > + RF Transmitter + + )} + + {bounds && ( + + )} + + {/* Clear RF Coverage Button */} + {observer && ( +
+ +
+ )} + + {/* RF Coverage Loading Status */} + {isCalculating && ( +
+
+ +
+ CALCULATING RF COVERAGE +
+
+ Running ITM propagation model... +
+
+ )} + + ); +}; + +export default CoverageLayerManager; diff --git a/src/components/Map/layers/LinkLayerManager.jsx b/src/components/Map/layers/LinkLayerManager.jsx new file mode 100644 index 0000000..ceef0c9 --- /dev/null +++ b/src/components/Map/layers/LinkLayerManager.jsx @@ -0,0 +1,56 @@ +import React from 'react'; +import LinkLayer from '../LinkLayer'; +import LinkAnalysisPanel from '../LinkAnalysisPanel'; +import { ImageOverlay } from 'react-leaflet'; + +const LinkLayerManager = ({ + active, + locked, + nodes, setNodes, + linkStats, setLinkStats, + coverageOverlay, setCoverageOverlay, + propagationSettings, setPropagationSettings, + budget, distance, units, + onManualClick +}) => { + // We render LinkLayer even if inactive if there are nodes? + // Usually map clears nodes when tool changes, but if we want persistence: + // The original MapContainer rendered LinkLayer with `active={toolMode === "link"}`. + + return ( + <> + + {coverageOverlay && ( + + )} + {/* Overlay Panel */} + {nodes.length === 2 && ( + + )} + + ); +}; + +export default LinkLayerManager; diff --git a/src/components/Map/layers/OptimizationLayerManager.jsx b/src/components/Map/layers/OptimizationLayerManager.jsx new file mode 100644 index 0000000..63b3783 --- /dev/null +++ b/src/components/Map/layers/OptimizationLayerManager.jsx @@ -0,0 +1,164 @@ +import React from 'react'; +import { Marker, Popup, Polyline, ImageOverlay, useMapEvents } from 'react-leaflet'; +import L from 'leaflet'; +import OptimizationLayer from '../OptimizationLayer'; +import useSimulationStore from '../../../store/useSimulationStore'; + +const MultiSiteClickHandler = ({ onLocationSelect }) => { + useMapEvents({ + click(e) { + onLocationSelect({ lat: e.latlng.lat, lng: e.latlng.lng }); + } + }); + return null; +}; + +const OptimizationLayerManager = ({ + active, + setActive, + siteAnalysisMode, + lastClickedLocation, setLastClickedLocation, + onStateUpdate, + weights, + simNodes, simResults, interNodeLinks, compositeOverlay, + units +}) => { + + // Proactive addition logic moved here + const handleLocationSelect = (loc) => { + setLastClickedLocation(loc); + useSimulationStore.getState().addNode({ + lat: loc.lat, + lon: loc.lng, + height: 10, + name: `Node ${simNodes.length + 1}` + }); + }; + + if (!active && !simNodes.length && !compositeOverlay) return null; + + return ( + <> + {/* Multi-Site Click Handler */} + {active && siteAnalysisMode === 'manual' && ( + + )} + + + + {/* Multi-Site Composite Overlay */} + {compositeOverlay && compositeOverlay.bounds && ( + + )} + + {/* Temporary Node Marker */} + {active && siteAnalysisMode === 'manual' && lastClickedLocation && ( + `, + iconSize: [16, 16], + iconAnchor: [8, 8], + })} + > + New Site Candidate + + )} + + {/* Simulation Nodes Rendering */} + {(active || simNodes.length > 0) && simNodes.map((node) => ( + ${simResults ? '✓' : ''}`, + iconSize: [14, 14], + iconAnchor: [7, 7], + })} + > + + {node.name}
+ Lat: {node.lat.toFixed(5)}
+ Lon: {node.lon.toFixed(5)}
+ {simResults && Array.isArray(simResults) && (() => { + const res = simResults.find(r => Math.abs(r.lat - node.lat) < 0.0001 && Math.abs(r.lon - node.lon) < 0.0001); + if (!res) return null; + return ( +
+
+ Elevation: + + {units === 'imperial' + ? `${(res.elevation * 3.28084).toFixed(1)} ft` + : `${res.elevation} m`} + +
+
+ Coverage: + + {units === 'imperial' + ? `${(res.coverage_area_km2 * 0.386102).toFixed(2)} mi²` + : `${res.coverage_area_km2} km²`} + +
+
+ ({res.coverage_points} visible points) +
+
+ ); + })()} +
+
+ ))} + + {/* Inter-node link quality polylines */} + {simResults && interNodeLinks && interNodeLinks.map((link, i) => { + const nodeA = simResults[link.node_a_idx]; + const nodeB = simResults[link.node_b_idx]; + if (!nodeA || !nodeB) return null; + const colorMap = { viable: '#00f2ff', degraded: '#ffd700', blocked: '#ff4444', unknown: '#888' }; + const color = colorMap[link.status] || '#888'; + const dashArray = link.status === 'blocked' ? '6 6' : link.status === 'degraded' ? '10 4' : null; + return ( + + ); + })} + + ); +}; + +export default OptimizationLayerManager; diff --git a/src/components/Map/layers/ViewshedLayerManager.jsx b/src/components/Map/layers/ViewshedLayerManager.jsx new file mode 100644 index 0000000..cf7d3d0 --- /dev/null +++ b/src/components/Map/layers/ViewshedLayerManager.jsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { Marker, Popup, Circle } from 'react-leaflet'; +import ViewshedControl from '../Controls/ViewshedControl'; + +const ViewshedLayerManager = ({ + active, + observer, setObserver, + runAnalysis, + isCalculating, + progress, + maxDist, setMaxDist, + clear, + isMobile +}) => { + if (!active) return null; + + const debugRadiusCircle = observer && maxDist ? { center: observer, radius: maxDist } : null; + + const buttonStyle = { + background: "rgba(255, 50, 50, 0.9)", + color: "#fff", + border: "1px solid rgba(255, 100, 100, 0.5)", + padding: "0 12px", + height: "36px", + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: "4px", + cursor: "pointer", + fontWeight: "bold", + fontSize: "14px", + boxShadow: "0 2px 5px rgba(0,0,0,0.5)", + transition: "all 0.2s ease", + }; + + return ( + <> + {observer && ( + { + const { lat, lng } = e.target.getLatLng(); + // Preserve antenna height on drag + const currentHeight = observer?.height || 2.0; + setObserver({ lat, lng, height: currentHeight }); + runAnalysis({ lat, lng, height: currentHeight }, maxDist); + }, + }} + > + Viewshed Transmitter + + )} + + {debugRadiusCircle && ( + + )} + + { + if (observer) { + runAnalysis(observer, maxDist); + } + }} + isMobile={isMobile} + /> + + {/* Clear Viewshed Button */} + {observer && ( +
+ +
+ )} + + ); +}; + +export default ViewshedLayerManager; diff --git a/src/context/EnvironmentContext.jsx b/src/context/EnvironmentContext.jsx new file mode 100644 index 0000000..3c040ef --- /dev/null +++ b/src/context/EnvironmentContext.jsx @@ -0,0 +1,51 @@ +import React, { createContext, useContext, useState, useMemo } from 'react'; + +// ITM Environment Constants +export const GROUND_TYPES = { + "Average Ground": { epsilon: 15.0, sigma: 0.005 }, + "Poor Ground": { epsilon: 4.0, sigma: 0.001 }, + "Good Ground": { epsilon: 25.0, sigma: 0.02 }, + "Fresh Water": { epsilon: 81.0, sigma: 0.01 }, + "Sea Water": { epsilon: 81.0, sigma: 5.0 }, + "City / Industrial": { epsilon: 5.0, sigma: 0.001 }, + Farmland: { epsilon: 15.0, sigma: 0.01 }, +}; + +export const CLIMATE_ZONES = { + 1: "Equatorial", + 2: "Continental Subtropical", + 3: "Maritime Subtropical", + 4: "Desert", + 5: "Continental Temperate", + 6: "Maritime Temperate Over Land", + 7: "Maritime Temperate Over Sea", +}; + +const EnvironmentContext = createContext(); + +export const useEnvironment = () => useContext(EnvironmentContext); + +export const EnvironmentProvider = ({ children }) => { + // ITM Environmental + const [kFactor, setKFactor] = useState(1.33); // Standard Refraction + const [clutterHeight, setClutterHeight] = useState(0); // Forest/Urban Obstruction (m) + const [groundType, setGroundType] = useState("Average Ground"); + const [climate, setClimate] = useState(5); // Continental Temperate + + // Coverage / Viewshed Parameters + const [rxHeight, setRxHeight] = useState(2.0); // Receiver Height (m), default 2m (Handheld) + const [fadeMargin, setFadeMargin] = useState(10); // Fade Margin (dB), default 10dB + const [viewshedMaxDist, setViewshedMaxDist] = useState(25000); // Max Distance (m), default 25km + + const value = useMemo(() => ({ + kFactor, setKFactor, + clutterHeight, setClutterHeight, + groundType, setGroundType, + climate, setClimate, + rxHeight, setRxHeight, + fadeMargin, setFadeMargin, + viewshedMaxDist, setViewshedMaxDist + }), [kFactor, clutterHeight, groundType, climate, rxHeight, fadeMargin, viewshedMaxDist]); + + return {children}; +}; diff --git a/src/context/HardwareContext.jsx b/src/context/HardwareContext.jsx new file mode 100644 index 0000000..64fa368 --- /dev/null +++ b/src/context/HardwareContext.jsx @@ -0,0 +1,104 @@ +import React, { createContext, useContext, useState, useMemo } from "react"; +import { DEVICE_PRESETS, ANTENNA_PRESETS, CABLE_TYPES } from "../data/presets"; + +const HardwareContext = createContext(); + +export const useHardware = () => useContext(HardwareContext); + +export const HardwareProvider = ({ children }) => { + // --- NODE-SPECIFIC CONFIGURATION --- + const [editMode, setEditMode] = useState("GLOBAL"); // 'GLOBAL', 'A', 'B' + + const DEFAULT_CONFIG = { + device: "HELTEC_V3", + antenna: "DIPOLE", + txPower: 20, + antennaHeight: 9.144, // 30 feet in meters + antennaGain: ANTENNA_PRESETS.DIPOLE.gain, + cableType: "LMR400", + cableLength: 0.3048, // 1 ft in meters + }; + + const [nodeConfigs, setNodeConfigs] = useState({ + A: { ...DEFAULT_CONFIG }, + B: { ...DEFAULT_CONFIG }, + }); + + const [batchNodes, setBatchNodes] = useState([]); + + const updateConfig = (key, value) => { + setNodeConfigs((prev) => { + const newConfigs = { + A: { ...prev.A }, + B: { ...prev.B } + }; + + const nodesToUpdate = editMode === "GLOBAL" ? ["A", "B"] : [editMode]; + + nodesToUpdate.forEach(node => { + newConfigs[node][key] = value; + + // Side Effects Logic (Synchronous) + if (key === 'device') { + const deviceMax = DEVICE_PRESETS[value]?.tx_power_max; + if (deviceMax && newConfigs[node].txPower > deviceMax) { + newConfigs[node].txPower = deviceMax; + } + } + if (key === 'antenna') { + if (value !== 'CUSTOM') { + const correctGain = ANTENNA_PRESETS[value]?.gain; + if (correctGain !== undefined) { + newConfigs[node].antennaGain = correctGain; + } + } + } + }); + return newConfigs; + }); + }; + + // Derived Values + const currentConfig = editMode === "GLOBAL" ? nodeConfigs.A : nodeConfigs[editMode]; + + // Proxies + const selectedDevice = currentConfig.device; + const selectedAntenna = currentConfig.antenna; + const txPower = currentConfig.txPower; + const antennaHeight = currentConfig.antennaHeight; + const antennaGain = currentConfig.antennaGain; + const selectedCableType = currentConfig.cableType || "LMR400"; + const cableLength = currentConfig.cableLength !== undefined ? currentConfig.cableLength : 1; + + // Calculations + const deviceLoss = DEVICE_PRESETS[selectedDevice]?.loss || 0; + const cableConfig = CABLE_TYPES[selectedCableType] || CABLE_TYPES.LMR400; + const cableLossVal = deviceLoss + cableConfig.loss_per_meter * (parseFloat(cableLength) || 0); + const cableLoss = parseFloat(cableLossVal.toFixed(2)); + const erp = (txPower + antennaGain - cableLoss).toFixed(1); + + const value = useMemo(() => ({ + nodeConfigs, setNodeConfigs, + editMode, setEditMode, + batchNodes, setBatchNodes, + updateConfig, + + // Proxies + selectedDevice, setSelectedDevice: (val) => updateConfig("device", val), + selectedAntenna, setSelectedAntenna: (val) => updateConfig("antenna", val), + txPower, setTxPower: (val) => updateConfig("txPower", val), + antennaHeight, setAntennaHeight: (val) => updateConfig("antennaHeight", val), + antennaGain, setAntennaGain: (val) => updateConfig("antennaGain", val), + selectedCableType, setSelectedCableType: (val) => updateConfig("cableType", val), + cableLength, setCableLength: (val) => updateConfig("cableLength", val), + + // Derived + erp, + cableLoss, + + // Helpers + getAntennaHeightMeters: () => parseFloat(antennaHeight) || 0, + }), [nodeConfigs, editMode, batchNodes, selectedDevice, selectedAntenna, txPower, antennaHeight, antennaGain, selectedCableType, cableLength, erp, cableLoss]); + + return {children}; +}; diff --git a/src/context/RFContext.jsx b/src/context/RFContext.jsx index 3d62bf9..1c1ce8e 100644 --- a/src/context/RFContext.jsx +++ b/src/context/RFContext.jsx @@ -1,307 +1,60 @@ -import React, { createContext, useState, useContext, useEffect } from "react"; -import { - RADIO_PRESETS, - DEVICE_PRESETS, - ANTENNA_PRESETS, - CABLE_TYPES, -} from "../data/presets"; +import React, { createContext, useContext, useEffect, useMemo } from "react"; +import { UIProvider, useUI } from "./UIContext"; +import { EnvironmentProvider, useEnvironment, GROUND_TYPES, CLIMATE_ZONES } from "./EnvironmentContext"; +import { RadioProvider, useRadio } from "./RadioContext"; +import { HardwareProvider, useHardware } from "./HardwareContext"; +import { RADIO_PRESETS } from "../data/presets"; import { calculateLoRaSensitivity } from "../utils/rfMath"; -// ITM Environment Constants -export const GROUND_TYPES = { - "Average Ground": { epsilon: 15.0, sigma: 0.005 }, - "Poor Ground": { epsilon: 4.0, sigma: 0.001 }, - "Good Ground": { epsilon: 25.0, sigma: 0.02 }, - "Fresh Water": { epsilon: 81.0, sigma: 0.01 }, - "Sea Water": { epsilon: 81.0, sigma: 5.0 }, - "City / Industrial": { epsilon: 5.0, sigma: 0.001 }, - Farmland: { epsilon: 15.0, sigma: 0.01 }, -}; - -export const CLIMATE_ZONES = { - 1: "Equatorial", - 2: "Continental Subtropical", - 3: "Maritime Subtropical", - 4: "Desert", - 5: "Continental Temperate", - 6: "Maritime Temperate Over Land", - 7: "Maritime Temperate Over Sea", -}; +// Re-export constants for backward compatibility +export { GROUND_TYPES, CLIMATE_ZONES }; const RFContext = createContext(); export const useRF = () => { - return useContext(RFContext); -}; - -export const RFProvider = ({ children }) => { - // --- NODE-SPECIFIC CONFIGURATION --- - // GLOBAL: Edit defaults (applies to both if they haven't been overridden? Or just applies to both for now) - // A/B: Edit specific node - const [editMode, setEditMode] = useState("GLOBAL"); // 'GLOBAL', 'A', 'B' - const [toolMode, setToolMode] = useState("link"); // 'link', 'optimize', 'viewshed', 'rf_coverage', 'none' - const [sidebarIsOpen, setSidebarIsOpen] = useState(window.innerWidth > 768); - const [isMobile, setIsMobile] = useState(window.innerWidth < 768); - - useEffect(() => { - const handleResize = () => { - const mobile = window.innerWidth < 768; - setIsMobile(mobile); - }; - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, []); - - const DEFAULT_CONFIG = { - device: "HELTEC_V3", - antenna: "DIPOLE", - txPower: 20, - antennaHeight: 9.144, // 30 feet in meters - antennaGain: ANTENNA_PRESETS.DIPOLE.gain, - cableType: "LMR400", - cableLength: 0.3048, // 1 ft in meters - }; - - const [nodeConfigs, setNodeConfigs] = useState({ - A: { ...DEFAULT_CONFIG }, - B: { ...DEFAULT_CONFIG }, - }); - - // Helper to update state based on current mode - const updateConfig = (key, value) => { - setNodeConfigs((prev) => { - const newConfigs = { ...prev }; - if (editMode === "GLOBAL") { - // Global updates both nodes - newConfigs.A[key] = value; - newConfigs.B[key] = value; - } else { - newConfigs[editMode][key] = value; - } - return newConfigs; - }); - }; - - // Get current values based on mode - // If Global, we show A's values as representative (or defaults if we tracked them separately) - const currentConfig = - editMode === "GLOBAL" ? nodeConfigs.A : nodeConfigs[editMode]; - - // Proxies for compatibility with existing components - const selectedDevice = currentConfig.device; - const setSelectedDevice = (val) => updateConfig("device", val); - - const selectedAntenna = currentConfig.antenna; - const setSelectedAntenna = (val) => updateConfig("antenna", val); - - const txPower = currentConfig.txPower; - const setTxPower = (val) => updateConfig("txPower", val); - - const antennaHeight = currentConfig.antennaHeight; - const setAntennaHeight = (val) => updateConfig("antennaHeight", val); - - const antennaGain = currentConfig.antennaGain; - const setAntennaGain = (val) => updateConfig("antennaGain", val); - - const selectedCableType = currentConfig.cableType || "LMR400"; - const setSelectedCableType = (val) => updateConfig("cableType", val); - - const cableLength = - currentConfig.cableLength !== undefined ? currentConfig.cableLength : 1; - const setCableLength = (val) => updateConfig("cableLength", val); - - // --- END NODE SPECIFIC --- - - // Batch Processing - const [batchNodes, setBatchNodes] = useState([]); // Array of {id, name, lat, lng} - const [showBatchPanel, setShowBatchPanel] = useState(false); - - // Helper for Environment Variables (Runtime or Build-time) - const getEnv = (key, fallback) => { - // 1. Runtime Injection (Docker) - if (window._env_ && window._env_[key]) return window._env_[key]; - // 2. Build-time Injection (Vite) - if (import.meta.env[`VITE_${key}`]) return import.meta.env[`VITE_${key}`]; - // 3. Fallback - return fallback; - }; - - // Preferences - const [units, setUnits] = useState(getEnv("DEFAULT_UNITS", "imperial")); - const [mapStyle, setMapStyle] = useState( - getEnv("DEFAULT_MAP_STYLE", "dark_green"), - ); - - // Environmental - const [kFactor, setKFactor] = useState(1.33); // Standard Refraction - const [clutterHeight, setClutterHeight] = useState(0); // Forest/Urban Obstruction (m) - const [rxHeight, setRxHeight] = useState(2.0); // Receiver Height (m), default 2m (Handheld) - const [fadeMargin, setFadeMargin] = useState(10); // Fade Margin (dB), default 10dB - - // Viewshed Params - const [viewshedMaxDist, setViewshedMaxDist] = useState(25000); // Max Distance (m), default 25km - - // Signals - const [recalcTimestamp, setRecalcTimestamp] = useState(0); - - // ITM Environment State - const [groundType, setGroundType] = useState("Average Ground"); - const [climate, setClimate] = useState(5); // Continental Temperate - const triggerRecalc = () => setRecalcTimestamp(Date.now()); - - // Radio Params (SHARED LINK PARAMETERS) - const [selectedRadioPreset, setSelectedRadioPreset] = - useState("MESHCORE_PNW"); - const [freq, setFreq] = useState(RADIO_PRESETS.MESHCORE_PNW.freq); - const [bw, setBw] = useState(RADIO_PRESETS.MESHCORE_PNW.bw); - const [sf, setSf] = useState(RADIO_PRESETS.MESHCORE_PNW.sf); - const [cr, setCr] = useState(RADIO_PRESETS.MESHCORE_PNW.cr); - - // 1. Radio Preset Sync - useEffect(() => { - const preset = RADIO_PRESETS[selectedRadioPreset]; - if (selectedRadioPreset !== "CUSTOM") { - setFreq(preset.freq); - setBw(preset.bw); - setSf(preset.sf); - setCr(preset.cr); - if (preset.power) { - updateConfig("txPower", preset.power); - } + const context = useContext(RFContext); + if (!context) { + throw new Error("useRF must be used within an RFProvider"); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedRadioPreset]); - - // 2. Device Cap Sync - useEffect(() => { - setNodeConfigs((prev) => { - const next = { ...prev }; - ["A", "B"].forEach((node) => { - const deviceMax = DEVICE_PRESETS[next[node].device].tx_power_max; - if (next[node].txPower > deviceMax) { - next[node].txPower = deviceMax; - } - }); - return next; - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [nodeConfigs.A.device, nodeConfigs.B.device]); // Dependency on devices - - // 3. Antenna preset sync - useEffect(() => { - setNodeConfigs((prev) => { - const next = { ...prev }; - let changed = false; + return context; +}; - ["A", "B"].forEach((node) => { - const type = next[node].antenna; - const currentGain = next[node].antennaGain; - if (type !== "CUSTOM") { - const correctGain = ANTENNA_PRESETS[type].gain; - if (currentGain !== correctGain) { - next[node].antennaGain = correctGain; - changed = true; - } +const RFContent = ({ children }) => { + const ui = useUI(); + const env = useEnvironment(); + const radio = useRadio(); + const hardware = useHardware(); + + // Glue Logic: Update TX Power when Radio Preset changes (if preset has power) + useEffect(() => { + const preset = RADIO_PRESETS[radio.selectedRadioPreset]; + if (radio.selectedRadioPreset !== "CUSTOM" && preset?.power) { + hardware.updateConfig("txPower", preset.power); } - }); - - return changed ? next : prev; - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [nodeConfigs.A.antenna, nodeConfigs.B.antenna]); - - // Derived Values (Active Context) - const deviceLoss = DEVICE_PRESETS[selectedDevice].loss || 0; - const cableConfig = CABLE_TYPES[selectedCableType] || CABLE_TYPES.LMR400; - const cableLossVal = - deviceLoss + cableConfig.loss_per_meter * (parseFloat(cableLength) || 0); - const cableLoss = parseFloat(cableLossVal.toFixed(2)); - const erp = (txPower + antennaGain - cableLoss).toFixed(1); - - const value = { - // Mode State - editMode, - setEditMode, - toolMode, - setToolMode, - nodeConfigs, - - // Proxied Accessors (UI Compatibility) - selectedRadioPreset, - setSelectedRadioPreset, - selectedDevice, - setSelectedDevice, - selectedAntenna, - setSelectedAntenna, - txPower, - setTxPower, - antennaHeight, - setAntennaHeight, - antennaGain, - setAntennaGain, - selectedCableType, - setSelectedCableType, - cableLength, - setCableLength, - - // Shared Params - freq, - setFreq, - bw, - setBw, - sf, - setSf, - cr, - setCr, - - // ITM Environment - groundType, - setGroundType, - climate, - setClimate, - - // Derived & Globals - erp, - cableLoss, - units, - setUnits, - mapStyle, - setMapStyle, - kFactor, - setKFactor, - clutterHeight, - setClutterHeight, - rxHeight, - setRxHeight, - fadeMargin, - setFadeMargin, - - // Viewshed - viewshedMaxDist, - setViewshedMaxDist, - - // Batch - batchNodes, - setBatchNodes, - showBatchPanel, - setShowBatchPanel, - - // UI State - sidebarIsOpen, - setSidebarIsOpen, - isMobile, - - recalcTimestamp, - triggerRecalc, - - // Helpers - getAntennaHeightMeters: () => { - return parseFloat(antennaHeight) || 0; // State is always in Meters - }, - calculateSensitivity: () => { - return calculateLoRaSensitivity(sf, bw); - }, - }; + }, [radio.selectedRadioPreset]); + + const value = useMemo(() => ({ + ...ui, + ...env, + ...radio, + ...hardware, + // Helpers + calculateSensitivity: () => calculateLoRaSensitivity(radio.sf, radio.bw), + }), [ui, env, radio, hardware]); + + return {children}; +}; - return {children}; +export const RFProvider = ({ children }) => { + return ( + + + + + {children} + + + + + ); }; diff --git a/src/context/RadioContext.jsx b/src/context/RadioContext.jsx new file mode 100644 index 0000000..6b74001 --- /dev/null +++ b/src/context/RadioContext.jsx @@ -0,0 +1,43 @@ +import React, { createContext, useContext, useState, useMemo } from "react"; +import { RADIO_PRESETS } from "../data/presets"; + +const RadioContext = createContext(); + +export const useRadio = () => useContext(RadioContext); + +export const RadioProvider = ({ children }) => { + // Radio Params (SHARED LINK PARAMETERS) + const [selectedRadioPreset, _setSelectedRadioPreset] = useState("MESHCORE_PNW"); + const [freq, setFreq] = useState(RADIO_PRESETS.MESHCORE_PNW.freq); + const [bw, setBw] = useState(RADIO_PRESETS.MESHCORE_PNW.bw); + const [sf, setSf] = useState(RADIO_PRESETS.MESHCORE_PNW.sf); + const [cr, setCr] = useState(RADIO_PRESETS.MESHCORE_PNW.cr); + + // Recalc Signal + const [recalcTimestamp, setRecalcTimestamp] = useState(0); + const triggerRecalc = () => setRecalcTimestamp(Date.now()); + + // 1. Radio Preset Sync Logic (Synchronous) + const setSelectedRadioPreset = (val) => { + _setSelectedRadioPreset(val); + const preset = RADIO_PRESETS[val]; + // If not custom, force values + if (val !== "CUSTOM" && preset) { + setFreq(preset.freq); + setBw(preset.bw); + setSf(preset.sf); + setCr(preset.cr); + } + }; + + const value = useMemo(() => ({ + selectedRadioPreset, setSelectedRadioPreset, + freq, setFreq, + bw, setBw, + sf, setSf, + cr, setCr, + recalcTimestamp, triggerRecalc + }), [selectedRadioPreset, freq, bw, sf, cr, recalcTimestamp]); + + return {children}; +}; diff --git a/src/context/UIContext.jsx b/src/context/UIContext.jsx new file mode 100644 index 0000000..83986b0 --- /dev/null +++ b/src/context/UIContext.jsx @@ -0,0 +1,43 @@ +import React, { createContext, useContext, useState, useEffect, useMemo } from 'react'; + +const UIContext = createContext(); + +export const useUI = () => useContext(UIContext); + +export const UIProvider = ({ children }) => { + // Helper for Environment Variables + const getEnv = (key, fallback) => { + if (window._env_ && window._env_[key]) return window._env_[key]; + if (import.meta.env[`VITE_${key}`]) return import.meta.env[`VITE_${key}`]; + return fallback; + }; + + const [sidebarIsOpen, setSidebarIsOpen] = useState(window.innerWidth > 768); + const [isMobile, setIsMobile] = useState(window.innerWidth < 768); + const [toolMode, setToolMode] = useState('link'); // 'link', 'optimize', 'viewshed', 'rf_coverage', 'none' + const [showBatchPanel, setShowBatchPanel] = useState(false); + + // Preferences + const [units, setUnits] = useState(getEnv('DEFAULT_UNITS', 'imperial')); + const [mapStyle, setMapStyle] = useState(getEnv('DEFAULT_MAP_STYLE', 'dark_green')); + + useEffect(() => { + const handleResize = () => { + const mobile = window.innerWidth < 768; + setIsMobile(mobile); + }; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + const value = useMemo(() => ({ + sidebarIsOpen, setSidebarIsOpen, + isMobile, + toolMode, setToolMode, + showBatchPanel, setShowBatchPanel, + units, setUnits, + mapStyle, setMapStyle + }), [sidebarIsOpen, isMobile, toolMode, showBatchPanel, units, mapStyle]); + + return {children}; +};