From 7370b1959a400b0b3b6654ed594623d11c7533ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 03:18:40 +0000 Subject: [PATCH 1/3] fix(config): repair map center vars, add CARTO key support, realign docs Four related configuration/documentation problems. 1. Map center env vars had no effect (#23) MapContainer hardcoded Portland, OR and never read VITE_MAP_LAT/VITE_MAP_LNG. Two faults were stacked: the variables were unused in the source, and a VITE_-prefixed variable is inlined by Vite at build time, so setting one in docker-compose.yml could never reach the prebuilt image regardless. The initial view now resolves through a new src/utils/runtimeConfig.js (window._env_ -> import.meta.env.VITE_* -> default), and docker-entrypoint.sh writes MAP_LAT/MAP_LNG/MAP_ZOOM into env-config.js at container start. VITE_MAP_LAT/VITE_MAP_LNG remain accepted as deprecated aliases. Invalid or out-of-range values warn and fall back instead of handing Leaflet a NaN. public/env-config.js no longer ships populated defaults: because window._env_ takes priority, its baked-in values silently shadowed the VITE_* layer in dev. 2. CARTO basemap API key CARTO began watermarking unauthenticated raster tiles in August 2026. The key is applied server-side only -- the browser requests same-origin /basemaps/... and nginx (prod) or the Vite dev/preview server (dev) appends the key on the way out, so it is absent from the bundle, from env-config.js and from devtools. CARTO_API_KEY is deliberately unprefixed; a VITE_ prefix would inline it. nginx.conf became a template rendered at container start. The proxy clears the client query string so a caller cannot substitute their own key, restricts the path to well-formed tile URLs so it is not a general-purpose open proxy, and caches tiles locally to protect the monthly quota. Verified end to end: nginx config parses with and without a key, real tiles return as valid PNGs, cache goes MISS then HIT, and a build with a canary key leaves no trace in dist/. 3. .env.example realigned It documented only two elevation variables. It now covers the basemap key, frontend defaults, elevation, Redis and dev-only settings, and Compose substitutes from it so one .env drives both stacks. 4. Documentation README version corrected (was pinned at v1.16.1), config table rebuilt -- it listed a dark_matter style that does not exist and omitted topo_dark and most backend variables. Documentation/README.md linked elevation-scan.md, which has never existed. interactions.md had a duplicated step and a stale tool name. hardware-settings.md was missing presets and all cable types. site-analyzer.md and rf-simulator.md predated features the README already advertised. Also removes VITE_ELEVATION_DATASET, which configured nothing: the /elevation-batch endpoint ignores the dataset field in the request body. Closes #23 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UqP5vFS3rXNwGUgAvtRQt6 --- .env.example | 96 ++++++++++-- CHANGELOG.md | 35 +++++ Dockerfile | 6 +- Documentation/README.md | 9 +- Documentation/hardware-settings.md | 35 +++-- Documentation/interactions.md | 9 +- Documentation/link-analyzer.md | 1 + Documentation/rf-simulator.md | 6 + Documentation/site-analyzer.md | 17 +- README.md | 78 ++++++++-- docker-compose.dev.yml | 15 +- docker-compose.yml | 20 ++- docker-entrypoint.sh | 67 ++++++-- nginx.conf | 53 +++++++ public/env-config.js | 14 +- src/components/Map/MapContainer.jsx | 56 +++++-- src/context/UIContext.jsx | 12 +- src/utils/__tests__/runtimeConfig.test.js | 99 ++++++++++++ src/utils/elevation.js | 10 +- src/utils/runtimeConfig.js | 54 +++++++ vite.config.js | 181 +++++++++++++--------- 21 files changed, 717 insertions(+), 156 deletions(-) create mode 100644 src/utils/__tests__/runtimeConfig.test.js create mode 100644 src/utils/runtimeConfig.js diff --git a/.env.example b/.env.example index 5bfbfae..f45bd47 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,89 @@ -# Elevation Data Configuration -# -# Option 1: Use public OpenTopoData (default, 1000 requests/day) -# Just leave these commented out or blank +# meshRF configuration # -# Option 2: Use a custom OpenTopoData instance (self-hosted or paid) -# ELEVATION_API_URL=http://your-opentopodata-instance:5000 -# ELEVATION_DATASET=srtm30m +# Copy to `.env` in the project root -- docker compose reads it automatically +# for both docker-compose.yml (production) and docker-compose.dev.yml (dev). +# Every setting below is optional; the defaults shown are what you get if the +# variable is unset. # -# Available datasets on public API: srtm30m, srtm90m, aster30m, etopo1, ned10m -# See: https://www.opentopodata.org/datasets/ +# cp .env.example .env +# +# `.env` is gitignored and dockerignored, so it never lands in a commit or an +# image layer. + +# ============================================================================= +# Map Basemaps (CARTO) +# ============================================================================= +# +# CARTO began requiring an API key for its raster basemaps in August 2026. +# Without one, the `dark`, `dark_green` and `light` styles still load but are +# stamped with an "API KEY REQUIRED" watermark. The `topo`, `topo_dark` and +# `satellite` styles come from Esri and need no key. +# +# Get a free key (5M tile requests/month, no account needed): +# https://carto.com/basemaps/apikey/ +# +# The key is used server-side only. Nginx (production) and the Vite dev server +# both append it to tile requests as they pass through, so it is never written +# into the JavaScript bundle, never appears in env-config.js, and never shows +# up in the browser's network tab. Do NOT rename this to VITE_CARTO_API_KEY -- +# a VITE_ prefix would inline it into the client bundle and publish it to every +# visitor. +# +# CARTO_API_KEY= + +# ============================================================================= +# Frontend Defaults +# ============================================================================= +# +# In production these are applied when the container starts, so changing them +# needs only a `docker compose up -d`, not an image rebuild. + +# Initial map center and zoom. Default: Portland, OR. +# MAP_LAT=45.5152 +# MAP_LNG=-122.6784 +# MAP_ZOOM=13 + +# Initial map theme. +# Options: dark, dark_green, light, topo, topo_dark, satellite +# DEFAULT_MAP_STYLE=dark_green + +# Measurement system. Options: imperial, metric +# DEFAULT_UNITS=imperial + +# ============================================================================= +# Elevation Data (rf-engine) +# ============================================================================= +# +# Option 1: Use the bundled self-hosted OpenTopoData container (default). +# You supply the terrain files -- see OPENTOPO_GUIDE.md for downloads. +# +# Option 2: Point at a different OpenTopoData instance (public or paid). +# The public API is capped at 1000 requests/day. +# +# ELEVATION_API_URL=http://opentopodata:5000 + +# Dataset served by that instance. Must match a dataset configured in +# data/opentopodata/config.yaml. +# Common choices: ned10m (high-res, US only), srtm30m (global), srtm90m, +# aster30m, etopo1. See https://www.opentopodata.org/datasets/ +# ELEVATION_DATASET=ned10m + +# ============================================================================= +# Redis (caching + Celery broker) +# ============================================================================= +# +# Change this before exposing meshRF beyond localhost -- the default is a +# well-known placeholder shared by every install. +# REDIS_PASSWORD=changeme +# REDIS_HOST=redis +# REDIS_PORT=6379 + +# ============================================================================= +# Development Only (docker-compose.dev.yml) +# ============================================================================= +# +# Hostnames the Vite dev server will accept, for running behind a reverse proxy +# or tunnel. Comma-separated, or `true` to allow any host. This is a Vite dev +# server option and has no effect on the production image, which serves through +# Nginx. +# ALLOWED_HOSTS=meshrf.example.com,localhost diff --git a/CHANGELOG.md b/CHANGELOG.md index cea5afb..b1d26e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **CARTO Basemap API Key Support**: CARTO began requiring an API key for its raster basemaps in August 2026; without one the `dark`, `dark_green` and `light` styles render with an "API KEY REQUIRED" watermark. Set `CARTO_API_KEY` in `.env` (free key: ). The key is applied **server-side only** — the browser requests tiles from a same-origin `/basemaps/...` path, and Nginx (production) or the Vite dev/preview server (development) appends the key on the way to CARTO. It is therefore absent from the JS bundle, from `env-config.js` and from anything visible in devtools. The variable is deliberately *not* `VITE_`-prefixed, since Vite inlines those into the client bundle. + - Nginx caches proxied tiles locally (30 days, 512 MB) so repeat views do not spend the account's monthly quota, and the proxy clears any client-supplied query string so a caller cannot substitute their own key. The location regex only accepts well-formed `{style}/{z}/{x}/{y}.png` paths, so it cannot be driven as a general-purpose open proxy. + - `nginx.conf` is now a template rendered by `docker-entrypoint.sh` at container start, so the key is supplied at deploy time rather than baked into the published image. +- `MAP_ZOOM` configures the initial zoom level, alongside the now-working `MAP_LAT` / `MAP_LNG`. +- `src/utils/runtimeConfig.js` centralizes runtime configuration lookups (`window._env_` → `import.meta.env.VITE_*` → default), with range validation for numeric settings and unit tests covering both layers. + +### Fixed + +- **Map center environment variables had no effect** ([#23](https://github.com/d3mocide/MeshRF/issues/23)): `MapContainer` hardcoded Portland, OR and never read `VITE_MAP_LAT` / `VITE_MAP_LNG`. Two separate faults were involved — the variables were unused in the source, and `VITE_`-prefixed variables are inlined by Vite at *build* time, so setting them in `docker-compose.yml` could never reach the prebuilt image regardless. The initial view now resolves through `runtimeConfig`, and `docker-entrypoint.sh` writes `MAP_LAT` / `MAP_LNG` / `MAP_ZOOM` into `env-config.js` at container start. `VITE_MAP_LAT` / `VITE_MAP_LNG` are still accepted as deprecated aliases. Invalid or out-of-range values now warn and fall back to the default instead of handing Leaflet a `NaN`. +- `public/env-config.js` no longer ships populated defaults. Because `window._env_` takes priority over `import.meta.env`, its baked-in values silently shadowed the `VITE_*` variables during `npm run dev` — part of why the map-center settings appeared to do nothing. +- `vite preview` had no proxy configuration, so a built app served through it lost both `/api` and basemap proxying. Both servers now share one proxy definition. + +### Changed + +- **`.env.example` rewritten**. It previously documented only two elevation variables and omitted everything else the stack actually reads. It now covers the basemap key, frontend defaults, elevation, Redis and dev-only settings, and Compose substitutes from it (`${MAP_LAT:-45.5152}`), so one `.env` drives both the production and development stacks. +- `README.md` version corrected to match `package.json` (was pinned at v1.16.1), configuration table rebuilt — it listed a `dark_matter` style that does not exist and omitted `topo_dark`, `MAP_ZOOM`, `ELEVATION_*`, `REDIS_PASSWORD` and `ALLOWED_HOSTS` — and a Basemap API Key section added. +- `MAP_STYLES` moved out of the `MapContainer` render body; it was rebuilt on every render, and the repeated attribution strings are now shared constants. +- `ALLOWED_HOSTS` removed from `docker-compose.yml` and added to `docker-compose.dev.yml`. It is a Vite dev-server option and had no effect on the production image, which serves through Nginx. + +### Removed + +- `VITE_ELEVATION_DATASET` read from `src/utils/elevation.js`. The `/elevation-batch` endpoint ignores the `dataset` field in the request body — dataset selection is made server-side by the rf-engine's `ELEVATION_DATASET` — so the variable configured nothing. + +### Documentation + +- `Documentation/README.md` linked to `elevation-scan.md`, which has never existed; the tool is now Site Analysis. Added the missing Tool Interactions and PWA guide links, and `link-analyzer.md` to the README's documentation list. +- `interactions.md` had a duplicated step 3/4 from a copy-paste error, and still referred to the renamed "Elevation Scan" tool. Its closing tip recommended Hata for verifying links, which assumes flat terrain; it now points at Bullington/ITM. +- `hardware-settings.md` was missing the Lilygo T-Deck and Custom Device presets, misnamed "Station G2 (High Power)", and documented no cable types despite the cable loss calculator shipping six. Preset names and gains realigned with `src/data/presets.js`. +- `site-analyzer.md` Multi-Site section predated the Inter-Node Link Matrix, Mesh Topology, Marginal Coverage and per-node coverage colors that the README already advertised. +- `rf-simulator.md` did not mention that coverage is computed with WASM ITM, nor the Ground Type / Climate Zone / Reliability inputs. `link-analyzer.md` gained the Reliability parameter. + ## [1.17.0] - 2026-08-06 ### Added diff --git a/Dockerfile b/Dockerfile index 28e4a6b..a817aed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,8 +15,10 @@ FROM nginx:alpine # Copy build artifacts to Nginx html directory COPY --from=builder /app/dist /usr/share/nginx/html -# Copy custom Nginx config -COPY nginx.conf /etc/nginx/conf.d/default.conf +# Copy custom Nginx config as a template. The entrypoint renders it to +# conf.d/default.conf so CARTO_API_KEY can be supplied at container start +# rather than baked into the published image. +COPY nginx.conf /etc/nginx/templates/default.conf.template # Copy Entrypoint Script COPY docker-entrypoint.sh / diff --git a/Documentation/README.md b/Documentation/README.md index 8a775cb..b82d270 100644 --- a/Documentation/README.md +++ b/Documentation/README.md @@ -7,10 +7,15 @@ Welcome to the MeshRF Documentation site. MeshRF is a powerful web-based toolset - [**Link Analyzer**](./link-analyzer.md) - Point-to-point link budget and Fresnel zone analysis. - [**Viewshed**](./viewshed.md) - Optical line-of-sight analysis using terrain data. - [**RF Simulator**](./rf-simulator.md) - Radio propagation heatmaps and coverage analysis. -- [**Elevation Scan**](./elevation-scan.md) - Rapid terrain analysis to find ideal transmitter locations. +- [**Site Analysis**](./site-analyzer.md) - Rapid terrain analysis to find ideal transmitter locations, plus multi-site mesh planning. - [**Hardware Settings**](./hardware-settings.md) - Detailed guide on devices, antennas, and radio presets. - [**Batch Processing**](./batch-processing.md) - Bulk analysis and mesh report generation via CSV. +## Guides + +- [**Tool Interactions**](./interactions.md) - How the tools combine into a planning workflow. +- [**PWA Guide**](./pwa-guide.md) - Installing meshRF on desktop and mobile. + ## Getting Started 1. **Select a Tool**: Use the toolbar at the top of the map to select your analysis mode. @@ -22,7 +27,7 @@ Welcome to the MeshRF Documentation site. MeshRF is a powerful web-based toolset ## How it Works -MeshRF combines high-resolution terrain data (DEM) with specialized RF propagation models (Free Space Path Loss, Okumura-Hata) to provide accurate predictions for wireless network performance. +MeshRF combines high-resolution terrain data (DEM) with specialized RF propagation models — Free Space Path Loss, Okumura-Hata / COST 231, Bullington diffraction, and ITM (Longley-Rice) — to provide accurate predictions for wireless network performance. --- diff --git a/Documentation/hardware-settings.md b/Documentation/hardware-settings.md index a8b7685..aca5641 100644 --- a/Documentation/hardware-settings.md +++ b/Documentation/hardware-settings.md @@ -10,27 +10,42 @@ MeshRF allows you to configure specific hardware parameters to accurately simula Selecting a device preset automatically configures the **Max TX Power** and **Internal Cable Loss**. -| Device | Max TX Power | Default Loss | -| --------------------------- | ------------ | ------------ | -| **Heltec V3** | 22 dBm | 1.5 dB | -| **Heltec V4 (High Power)** | 28 dBm | 1.5 dB | -| **Seeed Studio Xiao** | 22 dBm | 2.0 dB | -| **RAK WisBlock 4631** | 22 dBm | 0.5 dB | -| **Station G2 (High Power)** | 37 dBm (5W) | 0.5 dB | +| Device | Max TX Power | Default Loss | +| ------------------------------- | ------------ | ------------ | +| **Heltec V3** | 22 dBm | 1.5 dB | +| **Heltec V4 (High Power)** | 28 dBm | 1.5 dB | +| **Seeed Studio Xiao (SX1262)** | 22 dBm | 2.0 dB | +| **Lilygo T-Deck** | 22 dBm | 2.0 dB | +| **RAK WisBlock 4631** | 22 dBm | 0.5 dB | +| **Station G2** | 37 dBm (5W) | 0.5 dB | +| **Custom Device** | 37 dBm | 0.0 dB | ## 2. Antenna Types The antenna type determines the gain (dBi) added to your signal. -- **Stubby (2.15 dBi)**: Standard small antenna included with most modules. +- **Stock / Stubby (2.15 dBi)**: Standard small antenna included with most modules. - **Standard Dipole (3.0 dBi)**: Common half-wave dipole. -- **Fiberglass Omni (5.8 - 8.0 dBi)**: High-gain base station antennas for broad coverage. +- **Fiberglass Omni — Medium (5.8 dBi) / High (8.0 dBi)**: High-gain base station antennas for broad coverage. - **Yagi (11.0 dBi)**: Directional antenna for long-range point-to-point links. - **Custom**: Manually enter any gain value. +### Cable Types + +The **Cable Loss Calculator** derives feedline loss from cable type and run length: + +| Cable | Loss per meter | +| ----------------- | -------------- | +| **1/2" Heliax** | 0.038 dB | +| **LMR-400** | 0.128 dB | +| **LMR-240** | 0.249 dB | +| **RG-8X** | 0.262 dB | +| **RG-58** | 0.500 dB | +| **None / Direct** | 0.000 dB | + ## 3. Radio Configuration (LoRa) -For the **RF Simulator** and **Link Analyzer**, these parameters define the signal's robustnes: +For the **RF Simulator** and **Link Analyzer**, these parameters define the signal's robustness: - **Frequency (MHz)**: Higher frequencies (e.g., 915MHz) suffer more path loss than lower ones (e.g., 433MHz). - **Bandwidth (BW)**: Narrower bandwidths increase sensitivity but decrease data rate. diff --git a/Documentation/interactions.md b/Documentation/interactions.md index 9ee45ce..2e0dc31 100644 --- a/Documentation/interactions.md +++ b/Documentation/interactions.md @@ -6,11 +6,10 @@ MeshRF is most powerful when its tools are used in combination. This guide expla A typical planning cycle often looks like this: -1. **Elevation Scan**: Start by scanning a wide area to find the highest potential site. +1. **Site Analysis**: Start by scanning a wide area to find the highest potential site. 2. **Viewshed**: Place an observer on the #1 ranked spot to verify visual coverage of your target area. 3. **RF Simulator**: Switch to the simulator to see how signal strength behaves with realistic hardware settings from that same spot. -4. **RF Simulator**: Switch to the simulator to see how signal strength behaves with realistic hardware settings from that same spot. -5. **Link Analyzer**: Finally, draw a point-to-point link between your new site and an existing node to verify the backbone connection. +4. **Link Analyzer**: Finally, draw a point-to-point link between your new site and an existing node to verify the backbone connection. ## Navigation & Controls @@ -35,5 +34,5 @@ Changing the **Transmitter Height** in the Global Parameters sidebar will instan ## Tips for Success -- Use the **Topo Map** style when using the **Elevation Scan** to better understand the land features being analyzed. -- Always verify high-margin links with the **Realistic (Hata)** propagation model before finalizing a site. +- Use the **Topo Map** style when running **Site Analysis** to better understand the land features being analyzed. +- Always verify high-margin links with a terrain-aware model (**Bullington** or **ITM**) before finalizing a site. The Hata family assumes flat terrain. diff --git a/Documentation/link-analyzer.md b/Documentation/link-analyzer.md index 04152f2..98d184a 100644 --- a/Documentation/link-analyzer.md +++ b/Documentation/link-analyzer.md @@ -27,6 +27,7 @@ The **Link Analyzer** is designed for detailed point-to-point analysis between t | **Antenna Height** | Increases clearance and reduces path loss. | | **Frequency** | Higher frequencies have higher path loss and smaller Fresnel zones. | | **Environment** | Urban vs. Rural affects the path loss calculation in Realistic mode. | +| **Reliability** | ITM only. Best Case (10%) / Typical (50%) / Reliable (90%) statistical confidence. Higher confidence predicts more path loss. | > [!TIP] > Use the **Lock** button to freeze a link and adjust transmitter parameters without losing your placement. diff --git a/Documentation/rf-simulator.md b/Documentation/rf-simulator.md index 3a414da..441a975 100644 --- a/Documentation/rf-simulator.md +++ b/Documentation/rf-simulator.md @@ -2,11 +2,17 @@ The **RF Simulator** provides a radio propagation heatmap from a transmitter. Unlike the optical Viewshed, it accounts for frequency-specific attenuation and signal quality metrics. +Coverage is computed with the **ITM (Longley-Rice)** model running as a WASM +module in the browser, so it is terrain-aware and needs no backend round-trip +once the module has loaded. + ## Features - **SNR Heatmap**: Color-coded visualization of signal quality (SNR) across the area. - **Threshold Awareness**: Fades out signals that fall below the configured receiver sensitivity. - **Multi-Parameter Support**: Factors in Frequency, TX Power, Antenna Gain, and Spreading Factor. +- **Environment-Aware**: Honours the **Ground Type** (permittivity/conductivity) and **Climate Zone** set in the Environment sidebar. +- **Reliability Modes**: The **Reliability** control selects ITM's statistical confidence — Best Case (10%), Typical (50%, default) or Reliable (90%). Planning at *Reliable* shows the coverage you can count on in poor conditions rather than on a median day. ## How to Use diff --git a/Documentation/site-analyzer.md b/Documentation/site-analyzer.md index 2621b7d..e337578 100644 --- a/Documentation/site-analyzer.md +++ b/Documentation/site-analyzer.md @@ -21,13 +21,26 @@ A powerful optimization engine that scans a radial area around a transmitter to ### 2. Multi-Site Manager (Manual Mode) -A dedicated interface for managing a list of manual candidate sites. +A dedicated interface for managing a list of manual candidate sites, with a +results panel split across **Sites**, **Links** and **Topology** tabs. **Features:** - **Candidate List**: Add/Remove potential sites manually. - **Comparison**: Toggle between different candidates to compare viewsheds. - **Conversion**: Easily promote a candidate site to a permanent Network Node. +- **Per-Node Coverage Colors**: Each selected node's coverage renders in its own + color rather than one flat composite mask, so overlapping sites are + distinguishable at a glance. Markers and the Sites table are color-matched. +- **Marginal Coverage**: Each site reports the percentage of area only *it* + covers, which surfaces redundant placements before you deploy them. +- **Inter-Node Link Matrix** (Links tab): After a scan, every site pair is + analysed for path loss and Fresnel clearance and rated Viable, Degraded or + Blocked. Coloured polylines are drawn on the map — cyan = viable, + gold = degraded, red = blocked. +- **Mesh Topology** (Topology tab): A BFS-based connectivity score, multi-hop + relay detection and an all-pairs path table, so you can tell whether your + proposed sites actually form a connected mesh rather than isolated clusters. ## 🚀 How to Use @@ -59,7 +72,7 @@ A dedicated interface for managing a list of manual candidate sites. - **Fresnel**: Prioritizes clear line-of-sight and Fresnel zone clearance. > [!TIP] -> Use **Coverage Analysis** to discovering the best reception areas, then switch to **Multi-Site Manager** to fine-tune specific locations. +> Use **Coverage Analysis** to discover the best reception areas, then switch to **Multi-Site Manager** to fine-tune specific locations. > [!NOTE] > "Ghost Nodes" (Best Signal markers) are temporary. To save a location, convert it to a node or add it to your Multi-Site list. diff --git a/README.md b/README.md index e1ff440..799e232 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# meshRF 📡 v1.16.1 +# meshRF 📡 v1.17.0 A professional-grade RF propagation and link analysis tool designed for LoRa Mesh networks (Meshtastic, Reticulum, Sidewinder). Built with **React**, **Leaflet**, and a high-fidelity physics core combining a **Python Geodetic Engine** with **High-Performance WASM Modules**. -meshRF is designed for **mission-critical availability**. It operates with **zero external API dependencies** for elevation data, serving high-resolution terrain data directly from self-hosted containers. Currently we do rely on exteranl API's for map tiles but that will be updated soon as well for full offline use. (optional) +meshRF is designed for **mission-critical availability**. It operates with **zero external API dependencies** for elevation data, serving high-resolution terrain data directly from self-hosted containers. Map tiles are still fetched from external providers (CARTO and Esri); full offline basemap support is on the roadmap. ![Link Analysis Demo](./public/meshrf-preview-1.7.3.png) @@ -46,6 +46,7 @@ meshRF is designed for **mission-critical availability**. It operates with **zer Detailed guides for specific tools: +- [📖 link-analyzer.md](./Documentation/link-analyzer.md) - Point-to-point link budgets & Fresnel zones. - [📖 site-analyzer.md](./Documentation/site-analyzer.md) - **Site Finder** & **Multi-Site** tools. - [📖 viewshed.md](./Documentation/viewshed.md) - Optical LOS analysis. - [📖 rf-simulator.md](./Documentation/rf-simulator.md) - Coverage heatmap simulation. @@ -114,16 +115,75 @@ meshRF is fully containerized and easy to deploy: By default, meshRF uses a local **OpenTopoData** instance. You must download elevation files (HGT/TIF) to the `./data/opentopodata` directory. 👉 **[See Setup Guide](./OPENTOPO_GUIDE.md)** for data download instructions. +4. **Map Basemaps**: + CARTO now requires an API key for its basemap tiles. See + [Basemap API Key](#-basemap-api-key-carto) below — it takes about a minute + and the free tier is generous. + ### ⚙️ Configuration (Docker) -You can customize the application behavior by setting environment variables in `docker-compose.yml`: +Copy `.env.example` to `.env` and edit it. Docker Compose picks it up +automatically for both the production and development stacks: + +```bash +cp .env.example .env +``` + +| Variable | Description | Default | +| ------------------- | --------------------------------------------------------------------------------------------- | -------------------- | +| `CARTO_API_KEY` | CARTO basemap key. Applied server-side, never exposed to the browser. | _(unset)_ | +| `MAP_LAT` | Initial map center latitude | `45.5152` | +| `MAP_LNG` | Initial map center longitude | `-122.6784` | +| `MAP_ZOOM` | Initial zoom level (0-20) | `13` | +| `DEFAULT_MAP_STYLE` | Initial map theme (`dark`, `dark_green`, `light`, `topo`, `topo_dark`, `satellite`) | `dark_green` | +| `DEFAULT_UNITS` | Measurement system (`imperial` or `metric`) | `imperial` | +| `ELEVATION_API_URL` | OpenTopoData endpoint used by the RF Engine | `http://opentopodata:5000` | +| `ELEVATION_DATASET` | Terrain dataset name, must exist in `data/opentopodata/config.yaml` | `ned10m` | +| `REDIS_PASSWORD` | Redis password. **Change this before exposing meshRF beyond localhost.** | `changeme` | +| `ALLOWED_HOSTS` | Dev server only: hostnames the Vite dev server accepts, or `true` for any | _(unset)_ | + +> [!NOTE] +> The frontend settings are applied when the container **starts**, so changing +> them needs only `docker compose up -d` — no image rebuild. They are written +> into `env-config.js` at boot rather than compiled into the bundle, which is +> why a `VITE_`-prefixed variable in `docker-compose.yml` has no effect on the +> published image. `VITE_MAP_LAT` / `VITE_MAP_LNG` are still accepted as +> deprecated aliases for `MAP_LAT` / `MAP_LNG`. + +### 🔑 Basemap API Key (CARTO) + +As of August 2026 CARTO requires an API key for its raster basemaps. Without +one, the `dark`, `dark_green` and `light` styles still render but carry an +**"API KEY REQUIRED"** watermark. + +1. Request a free key at **[carto.com/basemaps/apikey](https://carto.com/basemaps/apikey/)** + — no account needed, and the free tier covers 5 million tile requests/month. +2. Add it to your `.env`: + + ```bash + CARTO_API_KEY=your_key_here + ``` + +3. `docker compose up -d`. + +**The key is never sent to the browser.** meshRF requests tiles from its own +`/basemaps/...` path; Nginx (production) and the Vite dev server (development) +append the key as the request passes through to CARTO. It stays in the server +config, so it is absent from the JavaScript bundle, from `env-config.js`, and +from anything visible in devtools. Nginx also caches tiles locally, which keeps +repeat views off your monthly quota. + +> [!IMPORTANT] +> Never rename this to `VITE_CARTO_API_KEY`. Vite inlines any `VITE_`-prefixed +> variable into the client bundle, which would publish your key to every +> visitor. The unprefixed name is what keeps it server-side. + +> [!TIP] +> Prefer not to sign up at all? The `topo`, `topo_dark` and `satellite` styles +> are served by Esri and need no key. Set `DEFAULT_MAP_STYLE=topo_dark`. -| Variable | Description | Default | -| ------------------- | ---------------------------------------------------------------------------------------------- | ------------ | -| `DEFAULT_MAP_STYLE` | Initial map theme (options: `dark`, `light`, `dark_matter`, `dark_green`, `topo`, `satellite`) | `dark_green` | -| `DEFAULT_UNITS` | Measurement system (`imperial` or `metric`) | `imperial` | -| `VITE_MAP_LAT` | Initial map center latitude | `45.5152` | -| `VITE_MAP_LNG` | Initial map center longitude | `-122.6784` | +CARTO's free tier requires that the OpenStreetMap and CARTO attribution stays +visible on the map. meshRF displays it by default — please leave it in place. --- diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index b42e048..e8ccba3 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -11,8 +11,19 @@ services: - .:/app - /app/node_modules environment: - - VITE_MAP_LAT=45.5152 - - VITE_MAP_LNG=-122.6784 + # The dev server is Vite, which only exposes VITE_-prefixed variables to + # the browser. These are mapped from the same unprefixed names used by + # docker-compose.yml and .env, so one .env drives both stacks. + - VITE_DEFAULT_MAP_STYLE=${DEFAULT_MAP_STYLE:-dark_green} + - VITE_DEFAULT_UNITS=${DEFAULT_UNITS:-imperial} + - VITE_MAP_LAT=${MAP_LAT:-45.5152} + - VITE_MAP_LNG=${MAP_LNG:--122.6784} + - VITE_MAP_ZOOM=${MAP_ZOOM:-13} + # Deliberately NOT VITE_-prefixed: a VITE_ variable is inlined into the + # client bundle. vite.config.js reads this in Node and appends it to + # proxied tile requests, so the key stays server-side. + - CARTO_API_KEY=${CARTO_API_KEY:-} + - ALLOWED_HOSTS=${ALLOWED_HOSTS:-} - API_TARGET=http://rf-engine:5001 restart: unless-stopped networks: diff --git a/docker-compose.yml b/docker-compose.yml index 2d96dbc..fccbd9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,14 +5,18 @@ services: ports: - "80:80" environment: - # Hostname for reverse proxy - - ALLOWED_HOSTS=localhost - # Default Map Center (Portland, OR) - - VITE_MAP_LAT=45.5152 - - VITE_MAP_LNG=-122.6784 - # UI Defaults (Runtime Configurable) - - DEFAULT_MAP_STYLE=dark_green - - DEFAULT_UNITS=imperial + # --- UI Defaults (applied at container start, no rebuild needed) --- + - DEFAULT_MAP_STYLE=${DEFAULT_MAP_STYLE:-dark_green} + - DEFAULT_UNITS=${DEFAULT_UNITS:-imperial} + # Initial map center and zoom (default: Portland, OR) + - MAP_LAT=${MAP_LAT:-45.5152} + - MAP_LNG=${MAP_LNG:--122.6784} + - MAP_ZOOM=${MAP_ZOOM:-13} + # --- CARTO Basemaps --- + # Free key: https://carto.com/basemaps/apikey/ + # Injected into tile requests by Nginx, so it is never sent to the browser. + # Without it CARTO styles render with an "API KEY REQUIRED" watermark. + - CARTO_API_KEY=${CARTO_API_KEY:-} restart: always networks: - meshrf_net diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index fe687e2..77ca4e3 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,18 +1,67 @@ #!/bin/sh +set -eu -# Default values +# The production image is prebuilt, so Vite has already run by the time this +# container starts: a VITE_-prefixed variable set in docker-compose.yml can +# never reach the bundle. Anything an operator needs to change at deploy time +# is therefore written into window._env_ here and read back by +# src/utils/runtimeConfig.js. + +# UI defaults : "${DEFAULT_MAP_STYLE:=dark_green}" : "${DEFAULT_UNITS:=imperial}" +# Initial map view. VITE_MAP_LAT / VITE_MAP_LNG are accepted as deprecated +# aliases so existing docker-compose.yml files keep working. +: "${MAP_LAT:=${VITE_MAP_LAT:-45.5152}}" +: "${MAP_LNG:=${VITE_MAP_LNG:--122.6784}}" +: "${MAP_ZOOM:=${VITE_MAP_ZOOM:-13}}" + # Recreate config file -rm -rf /usr/share/nginx/html/env-config.js -touch /usr/share/nginx/html/env-config.js - -# Add assignment -echo "window._env_ = {" >> /usr/share/nginx/html/env-config.js -echo " DEFAULT_MAP_STYLE: \"${DEFAULT_MAP_STYLE}\"," >> /usr/share/nginx/html/env-config.js -echo " DEFAULT_UNITS: \"${DEFAULT_UNITS}\"," >> /usr/share/nginx/html/env-config.js -echo "};" >> /usr/share/nginx/html/env-config.js +# +# Only non-sensitive settings belong here -- this file is served to every +# visitor. CARTO_API_KEY is deliberately absent; it is injected server-side by +# the /basemaps proxy below. +CONFIG_FILE=/usr/share/nginx/html/env-config.js +rm -f "$CONFIG_FILE" + +{ + echo "window._env_ = {" + echo " DEFAULT_MAP_STYLE: \"${DEFAULT_MAP_STYLE}\"," + echo " DEFAULT_UNITS: \"${DEFAULT_UNITS}\"," + echo " MAP_LAT: \"${MAP_LAT}\"," + echo " MAP_LNG: \"${MAP_LNG}\"," + echo " MAP_ZOOM: \"${MAP_ZOOM}\"," + echo "};" +} > "$CONFIG_FILE" + +# Render the Nginx config. +# +# CARTO watermarks unauthenticated raster tiles, so the /basemaps proxy appends +# an API key on the way out. Keeping the key in the Nginx config (root-owned, +# never served) rather than in env-config.js is what stops it from being +# scraped out of the page by anyone who loads the app. +: "${CARTO_API_KEY:=}" +if [ -n "$CARTO_API_KEY" ]; then + CARTO_TILE_QUERY="?key=${CARTO_API_KEY}" +else + echo "meshRF: CARTO_API_KEY is not set -- CARTO basemaps will render with an" >&2 + echo " 'API KEY REQUIRED' watermark. Request a free key at" >&2 + echo " https://carto.com/basemaps/apikey/ or switch DEFAULT_MAP_STYLE" >&2 + echo " to topo / topo_dark / satellite, which need no key." >&2 + CARTO_TILE_QUERY="" +fi +export CARTO_TILE_QUERY + +# Belt-and-braces: Nginx creates its own proxy_cache_path directory at startup. +# Tolerate failure so a hardened deployment running as a non-root `user:` does +# not crash-loop here. +mkdir -p /var/cache/nginx/basemaps 2>/dev/null || true + +envsubst '${CARTO_TILE_QUERY}' \ + < /etc/nginx/templates/default.conf.template \ + > /etc/nginx/conf.d/default.conf +chmod 600 /etc/nginx/conf.d/default.conf # Execute the passed command (nginx) exec "$@" diff --git a/nginx.conf b/nginx.conf index 17f8640..445823a 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,3 +1,12 @@ +# Rendered to /etc/nginx/conf.d/default.conf by docker-entrypoint.sh. +# Only ${CARTO_TILE_QUERY} is substituted -- every other $variable below is +# left intact for nginx itself to evaluate at request time. + +# Cache CARTO tiles locally so repeat views do not spend the account's monthly +# tile quota (the free tier allows 5M requests/month). +proxy_cache_path /var/cache/nginx/basemaps levels=1:2 keys_zone=basemaps:10m + max_size=512m inactive=30d use_temp_path=off; + server { listen 80; server_tokens off; # Hide Nginx version @@ -17,6 +26,50 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + # --- CARTO Basemap Proxy --- + # + # CARTO began watermarking unauthenticated raster tiles in August 2026, so + # the tiles now need an API key. The browser requests tiles from this + # same-origin path and nginx appends the key on the way out, which means + # CARTO_API_KEY never reaches the client: it is not in the JS bundle, not in + # env-config.js, and not in any URL visible in devtools or the access log + # (nginx logs the original request URI, not the rewritten upstream one). + # + # The location regex accepts only well-formed {style}/{z}/{x}/{y}.png tile + # paths so this cannot be driven as a general-purpose open proxy. It must + # stay quoted: nginx reads a bare `{` as the start of a block. + location ~ "^/basemaps/[a-z0-9_/-]+/\d{1,2}/\d{1,7}/\d{1,7}(@2x)?\.png$" { + # Clear the client's query string first. Without this, nginx appends + # the original args after the rewrite's, so a request for + # `tile.png?key=theirs` would reach CARTO as `key=ours&key=theirs`. + set $args ""; + rewrite ^/basemaps/(.*)$ /$1${CARTO_TILE_QUERY} break; + + # No variables in proxy_pass, so nginx resolves the host at startup and + # needs no `resolver` directive. + proxy_pass https://basemaps.cartocdn.com; + proxy_http_version 1.1; + proxy_set_header Host basemaps.cartocdn.com; + proxy_set_header Connection ""; + # Do not forward the deployment's own hostname or client identity. + proxy_set_header Referer ""; + proxy_set_header Cookie ""; + proxy_set_header X-Forwarded-For ""; + proxy_ssl_server_name on; # Required for SNI against the CDN + + proxy_cache basemaps; + proxy_cache_key $uri; # Excludes the API key, so rotating it keeps the cache + proxy_cache_valid 200 30d; + proxy_cache_valid 404 1m; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + proxy_cache_lock on; + add_header X-Cache-Status $upstream_cache_status; + + proxy_hide_header Set-Cookie; + proxy_ignore_headers Set-Cookie; + expires 30d; + } + # --- Security Hardening --- # Block access to hidden files (except .well-known) diff --git a/public/env-config.js b/public/env-config.js index bb77dc7..6b23f81 100644 --- a/public/env-config.js +++ b/public/env-config.js @@ -1,4 +1,10 @@ -window._env_ = { - DEFAULT_MAP_STYLE: "dark_green", - DEFAULT_UNITS: "imperial", -}; +// Development placeholder. +// +// In the Docker image this file is regenerated by docker-entrypoint.sh on every +// container start -- that is how a prebuilt image picks up runtime settings +// without being rebuilt (see src/utils/runtimeConfig.js for the lookup order). +// +// Keep this object empty. Any key set here shadows the VITE_* variables used by +// `npm run dev`, which is exactly how the map-center settings silently stopped +// working. Never put a secret here either: this file is served to every visitor. +window._env_ = {}; diff --git a/src/components/Map/MapContainer.jsx b/src/components/Map/MapContainer.jsx index d2e8b8e..06323f2 100644 --- a/src/components/Map/MapContainer.jsx +++ b/src/components/Map/MapContainer.jsx @@ -37,6 +37,39 @@ import SiteAnalysisPanel from "./UI/SiteAnalysisPanel"; import SiteAnalysisResultsPanel from "./UI/SiteAnalysisResultsPanel"; import BatchNodesPanelWrapper from "./Controls/BatchNodesPanelWrapper"; +// Runtime config +import { getRuntimeNumber } from "../../utils/runtimeConfig"; + +// Initial map view. Portland, OR is the historical default and stays the +// fallback when MAP_LAT/MAP_LNG/MAP_ZOOM are unset or invalid. +const FALLBACK_MAP_LAT = 45.5152; +const FALLBACK_MAP_LNG = -122.6784; +const FALLBACK_MAP_ZOOM = 13; + +// CARTO began watermarking unauthenticated raster tiles in August 2026, so the +// tiles now need an API key. Requesting them same-origin lets nginx (prod) or +// the Vite dev server append the key server-side -- see the `/basemaps` proxy +// in nginx.conf and vite.config.js. The key is therefore never part of the +// bundle or of any URL the browser can see. +const cartoTiles = (style) => `/basemaps/${style}/{z}/{x}/{y}{r}.png`; + +// CARTO's free tier requires that this attribution stays visible on every map. +const CARTO_ATTRIBUTION = + '© OpenStreetMap contributors © CARTO'; +const ESRI_TOPO_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"; +const ESRI_IMAGERY_ATTRIBUTION = + "Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community"; + +const MAP_STYLES = { + dark: { url: cartoTiles("dark_all"), attribution: CARTO_ATTRIBUTION }, + dark_green: { url: cartoTiles("rastertiles/voyager"), attribution: CARTO_ATTRIBUTION, className: "dark-mode-tiles" }, + light: { url: cartoTiles("rastertiles/voyager"), attribution: CARTO_ATTRIBUTION }, + topo: { url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", attribution: ESRI_TOPO_ATTRIBUTION }, + topo_dark: { url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", attribution: ESRI_TOPO_ATTRIBUTION, className: "dark-mode-tiles" }, + satellite: { url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", attribution: ESRI_IMAGERY_ATTRIBUTION }, +}; + // Custom SVG marker icon const customMarkerIcon = L.divIcon({ html: ` @@ -166,15 +199,6 @@ const MapComponent = () => { } }, []); - // 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 @@ -264,7 +288,15 @@ const MapComponent = () => { }, [toolMode, viewshedLayer, rfResultLayer]); - const defaultPosition = [45.5152, -122.6784]; + // Resolved once on mount: MapContainer's center/zoom are initial-view props, + // so recomputing them on later renders would have no effect anyway. + const [initialView] = useState(() => ({ + center: [ + getRuntimeNumber("MAP_LAT", FALLBACK_MAP_LAT, { min: -90, max: 90 }), + getRuntimeNumber("MAP_LNG", FALLBACK_MAP_LNG, { min: -180, max: 180 }), + ], + zoom: getRuntimeNumber("MAP_ZOOM", FALLBACK_MAP_ZOOM, { min: 0, max: 20 }), + })); // Pass RF context explicitly to handler to avoid stale closures in event loop const rfContextFacade = useRF(); @@ -272,8 +304,8 @@ const MapComponent = () => { return (
diff --git a/src/context/UIContext.jsx b/src/context/UIContext.jsx index 11ef2d1..eeb2aa1 100644 --- a/src/context/UIContext.jsx +++ b/src/context/UIContext.jsx @@ -5,27 +5,21 @@ // production builds. /* eslint-disable react-refresh/only-export-components */ import React, { createContext, useContext, useState, useEffect, useMemo } from 'react'; +import { getRuntimeConfig } from '../utils/runtimeConfig'; 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')); + const [units, setUnits] = useState(getRuntimeConfig('DEFAULT_UNITS', 'imperial')); + const [mapStyle, setMapStyle] = useState(getRuntimeConfig('DEFAULT_MAP_STYLE', 'dark_green')); useEffect(() => { const handleResize = () => { diff --git a/src/utils/__tests__/runtimeConfig.test.js b/src/utils/__tests__/runtimeConfig.test.js new file mode 100644 index 0000000..d7d64e2 --- /dev/null +++ b/src/utils/__tests__/runtimeConfig.test.js @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { getRuntimeConfig, getRuntimeNumber } from '../runtimeConfig'; + +describe('getRuntimeConfig', () => { + beforeEach(() => { + delete window._env_; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + delete window._env_; + }); + + it('prefers window._env_, which is how the prebuilt Docker image is configured', () => { + vi.stubEnv('VITE_MAP_LAT', '1.1'); + window._env_ = { MAP_LAT: '41.2565' }; + expect(getRuntimeConfig('MAP_LAT')).toBe('41.2565'); + }); + + it('falls back to the build-time VITE_ variable used by npm run dev', () => { + vi.stubEnv('VITE_MAP_LAT', '41.2565'); + expect(getRuntimeConfig('MAP_LAT')).toBe('41.2565'); + }); + + it('falls back to the supplied default when nothing is set', () => { + expect(getRuntimeConfig('MAP_LAT', 'fallback')).toBe('fallback'); + }); + + it('ignores an empty window._env_ entry so the VITE_ layer still applies', () => { + vi.stubEnv('VITE_DEFAULT_UNITS', 'metric'); + window._env_ = { DEFAULT_UNITS: '' }; + expect(getRuntimeConfig('DEFAULT_UNITS', 'imperial')).toBe('metric'); + }); + + it('does not throw when window._env_ was never defined', () => { + expect(() => getRuntimeConfig('ANYTHING')).not.toThrow(); + expect(getRuntimeConfig('ANYTHING')).toBeUndefined(); + }); +}); + +describe('getRuntimeNumber', () => { + beforeEach(() => { + delete window._env_; + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + delete window._env_; + }); + + it('parses a configured coordinate', () => { + window._env_ = { MAP_LAT: '41.2565' }; + expect(getRuntimeNumber('MAP_LAT', 45.5152, { min: -90, max: 90 })).toBe(41.2565); + }); + + it('parses a negative longitude', () => { + window._env_ = { MAP_LNG: '-95.9345' }; + expect(getRuntimeNumber('MAP_LNG', -122.6784, { min: -180, max: 180 })).toBe(-95.9345); + }); + + it('accepts zero rather than treating it as unset', () => { + window._env_ = { MAP_LNG: '0' }; + expect(getRuntimeNumber('MAP_LNG', -122.6784, { min: -180, max: 180 })).toBe(0); + }); + + it('returns the fallback when unset', () => { + expect(getRuntimeNumber('MAP_ZOOM', 13, { min: 0, max: 20 })).toBe(13); + }); + + it('returns the fallback for a blank value', () => { + window._env_ = { MAP_ZOOM: ' ' }; + expect(getRuntimeNumber('MAP_ZOOM', 13, { min: 0, max: 20 })).toBe(13); + }); + + it('rejects a non-numeric value instead of handing Leaflet a NaN', () => { + window._env_ = { MAP_LAT: 'not-a-number' }; + expect(getRuntimeNumber('MAP_LAT', 45.5152, { min: -90, max: 90 })).toBe(45.5152); + expect(console.warn).toHaveBeenCalled(); + }); + + it('rejects an out-of-range latitude', () => { + window._env_ = { MAP_LAT: '120' }; + expect(getRuntimeNumber('MAP_LAT', 45.5152, { min: -90, max: 90 })).toBe(45.5152); + }); + + it('rejects an out-of-range longitude', () => { + window._env_ = { MAP_LNG: '-400' }; + expect(getRuntimeNumber('MAP_LNG', -122.6784, { min: -180, max: 180 })).toBe(-122.6784); + }); + + it('reads coordinates from the VITE_ layer used in dev', () => { + vi.stubEnv('VITE_MAP_LAT', '41.2565'); + vi.stubEnv('VITE_MAP_LNG', '-95.9345'); + expect(getRuntimeNumber('MAP_LAT', 45.5152, { min: -90, max: 90 })).toBe(41.2565); + expect(getRuntimeNumber('MAP_LNG', -122.6784, { min: -180, max: 180 })).toBe(-95.9345); + }); +}); diff --git a/src/utils/elevation.js b/src/utils/elevation.js index 1130322..90ee2ad 100644 --- a/src/utils/elevation.js +++ b/src/utils/elevation.js @@ -1,5 +1,8 @@ import * as turf from '@turf/turf'; +// Matches the `dataset` default on the rf-engine's BatchElevationRequest model. +const DEFAULT_ELEVATION_DATASET = 'ned10m'; + /** * Fetch elevation profile along a path using local RF Engine proxy * @param {Object} start - {lat, lng} @@ -38,8 +41,11 @@ export const fetchElevationPath = async (start, end, samples = 20) => { } // Call local RF-Engine OpenTopoData proxy - const baseUrl = '/api'; // Proxied to RF engine invite.config - const dataset = import.meta.env.VITE_ELEVATION_DATASET || 'ned10m'; + const baseUrl = '/api'; // Proxied to the RF engine, see vite.config.js + // The dataset is chosen server-side by the rf-engine's ELEVATION_DATASET + // environment variable; /elevation-batch accepts this field for wire + // compatibility but ignores it, so there is nothing to configure here. + const dataset = DEFAULT_ELEVATION_DATASET; const locationStr = lats.map((lat, i) => `${lat},${lngs[i]}`).join('|'); const response = await fetch(`${baseUrl}/elevation-batch`, { diff --git a/src/utils/runtimeConfig.js b/src/utils/runtimeConfig.js new file mode 100644 index 0000000..f474b94 --- /dev/null +++ b/src/utils/runtimeConfig.js @@ -0,0 +1,54 @@ +/** + * Runtime configuration accessor. + * + * Values resolve in priority order: + * 1. `window._env_` -- written by `docker-entrypoint.sh` when the container + * starts, so an operator can reconfigure the published image without + * rebuilding it. + * 2. `import.meta.env.VITE_` -- inlined by Vite at build time. This is + * the path that works for `npm run dev` and for images built from source. + * 3. The supplied fallback. + * + * The `window._env_` layer exists because the production image is prebuilt: + * a `VITE_`-prefixed variable set in `docker-compose.yml` is read at *build* + * time, so it can never reach an already-published image. Anything that must + * be configurable at deploy time has to come through `window._env_`. + * + * Never route a secret through here. Everything it returns is readable by + * anyone with the browser devtools open -- see the CARTO basemap proxy in + * `nginx.conf` and `vite.config.js` for how credentials are kept server-side. + */ +export const getRuntimeConfig = (key, fallback = undefined) => { + if (typeof window !== 'undefined' && window._env_ && window._env_[key]) { + return window._env_[key]; + } + const buildTime = import.meta.env[`VITE_${key}`]; + if (buildTime) return buildTime; + return fallback; +}; + +/** + * Reads a numeric runtime setting, rejecting anything unparseable or outside + * the accepted range so a typo in `docker-compose.yml` degrades to the default + * instead of handing Leaflet a NaN and blanking the map. + * + * @param {string} key - Config key, without the `VITE_` prefix. + * @param {number} fallback - Value used when unset or invalid. + * @param {Object} [bounds] - Optional inclusive `{ min, max }` range. + * @returns {number} + */ +export const getRuntimeNumber = (key, fallback, bounds = {}) => { + const raw = getRuntimeConfig(key); + if (raw === undefined || raw === null || `${raw}`.trim() === '') return fallback; + + const parsed = Number.parseFloat(raw); + const { min = -Infinity, max = Infinity } = bounds; + + if (!Number.isFinite(parsed) || parsed < min || parsed > max) { + console.warn( + `[meshRF] Ignoring invalid ${key} value "${raw}" -- using ${fallback} instead.` + ); + return fallback; + } + return parsed; +}; diff --git a/vite.config.js b/vite.config.js index 2648dbb..815983b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,83 +1,122 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import { VitePWA } from 'vite-plugin-pwa' +const CARTO_BASEMAP_ORIGIN = 'https://basemaps.cartocdn.com' + +/** + * Builds the dev/preview equivalent of the `/basemaps` proxy in nginx.conf. + * + * CARTO watermarks unauthenticated raster tiles, so they need an API key. The + * key is read from `CARTO_API_KEY` -- deliberately *without* a `VITE_` prefix, + * because a `VITE_`-prefixed variable is inlined into the client bundle, which + * would publish the key to every visitor. Appending it here keeps it in the + * Node process: the browser only ever requests same-origin `/basemaps/...`. + */ +const cartoProxy = (apiKey) => ({ + target: CARTO_BASEMAP_ORIGIN, + changeOrigin: true, + rewrite: (path) => { + // Drop any client-supplied query string so the key cannot be overridden. + const tilePath = path.replace(/^\/basemaps/, '').split('?')[0] + return apiKey ? `${tilePath}?key=${encodeURIComponent(apiKey)}` : tilePath + } +}) + // https://vite.dev/config/ -export default defineConfig({ - plugins: [ - react(), - VitePWA({ - registerType: 'prompt', - includeAssets: ['favicon.ico', 'icon.svg', 'apple-touch-icon.png', 'pwa-192x192.png', 'pwa-512x512.png'], - manifest: { - name: 'meshRF', - short_name: 'meshRF', - description: 'Advanced RF Link Analysis and Mesh Planning', - theme_color: '#0a0a0f', - background_color: '#0a0a0f', - display: 'standalone', - scope: '/', - start_url: '/', - orientation: 'portrait-primary', - icons: [ - { - src: 'pwa-192x192.png', - sizes: '192x192', - type: 'image/png' - }, - { - src: 'pwa-512x512.png', - sizes: '512x512', - type: 'image/png' - }, - { - src: 'pwa-512x512.png', - sizes: '512x512', - type: 'image/png', - purpose: 'any maskable' - } - ] - }, - workbox: { - runtimeCaching: [ - { - urlPattern: ({ url }) => url.pathname.startsWith('/api'), - handler: 'NetworkOnly', - options: { - backgroundSync: { - name: 'api-queue', - options: { - maxRetentionTime: 5 +export default defineConfig(({ mode }) => { + // Empty prefix so non-`VITE_` server-side settings in a local .env are + // visible to this config. These stay in Node; nothing here is exposed + // to the client via `define`. + const env = loadEnv(mode, process.cwd(), '') + const cartoApiKey = env.CARTO_API_KEY || '' + + const proxy = { + '/api': { + target: env.API_TARGET || 'http://localhost:5001', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, '') + }, + '/basemaps': cartoProxy(cartoApiKey) + } + + return { + plugins: [ + react(), + VitePWA({ + registerType: 'prompt', + includeAssets: ['favicon.ico', 'icon.svg', 'apple-touch-icon.png', 'pwa-192x192.png', 'pwa-512x512.png'], + manifest: { + name: 'meshRF', + short_name: 'meshRF', + description: 'Advanced RF Link Analysis and Mesh Planning', + theme_color: '#0a0a0f', + background_color: '#0a0a0f', + display: 'standalone', + scope: '/', + start_url: '/', + orientation: 'portrait-primary', + icons: [ + { + src: 'pwa-192x192.png', + sizes: '192x192', + type: 'image/png' + }, + { + src: 'pwa-512x512.png', + sizes: '512x512', + type: 'image/png' + }, + { + src: 'pwa-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'any maskable' + } + ] + }, + workbox: { + runtimeCaching: [ + { + urlPattern: ({ url }) => url.pathname.startsWith('/api'), + handler: 'NetworkOnly', + options: { + backgroundSync: { + name: 'api-queue', + options: { + maxRetentionTime: 5 + } } } } - } - ] + ] + }, + devOptions: { + enabled: true, + type: 'module' + } + }) + ], + server: { + host: true, // Needed for Docker + allowedHosts: env.ALLOWED_HOSTS + ? (env.ALLOWED_HOSTS === 'true' ? true : env.ALLOWED_HOSTS.split(',')) + : undefined, + watch: { + usePolling: true, // Needed for Windows file system in Docker }, - devOptions: { - enabled: true, - type: 'module' - } - }) - ], - server: { - host: true, // Needed for Docker - allowedHosts: process.env.ALLOWED_HOSTS - ? (process.env.ALLOWED_HOSTS === 'true' ? true : process.env.ALLOWED_HOSTS.split(',')) - : undefined, - watch: { - usePolling: true, // Needed for Windows file system in Docker + proxy + }, + + // `vite preview` does not inherit server.proxy, so the built app would lose + // both the API and basemap proxies without this. + preview: { + proxy }, - proxy: { - '/api': { - target: process.env.API_TARGET || 'http://localhost:5001', - changeOrigin: true, - rewrite: (path) => path.replace(/^\/api/, '') - } - }}, - test: { - globals: true, - environment: "jsdom", + test: { + globals: true, + environment: "jsdom", + } } }) From 6040eb503c724853bc544339adf52d26e52b21ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 03:23:26 +0000 Subject: [PATCH 2/3] fix(docker): render nginx template with sed instead of envsubst envsubst comes from gettext, which is not something we can rely on being installed in the nginx base image -- the alpine Dockerfile in nginx's own repository installs only curl and ca-certificates. If it is absent, the entrypoint fails under `set -e` and the container crash-loops on every start, which is a poor trade for a single string substitution. sed is part of busybox and therefore always present. The replacement escapes the characters sed treats specially (backslash, `&`, and the `|` delimiter) so an API key containing any of them still renders correctly. Verified: entrypoint renders correctly for plain keys and keys containing & | \ / + =, and for the no-key case; no placeholder is left behind; nginx -t passes on the rendered config in both states; and the proxy still injects the key and discards a client-supplied query string when run against a mock upstream. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UqP5vFS3rXNwGUgAvtRQt6 --- docker-entrypoint.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 77ca4e3..23c55e8 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -51,14 +51,22 @@ else echo " to topo / topo_dark / satellite, which need no key." >&2 CARTO_TILE_QUERY="" fi -export CARTO_TILE_QUERY # Belt-and-braces: Nginx creates its own proxy_cache_path directory at startup. # Tolerate failure so a hardened deployment running as a non-root `user:` does # not crash-loop here. mkdir -p /var/cache/nginx/basemaps 2>/dev/null || true -envsubst '${CARTO_TILE_QUERY}' \ +# Substitute with sed rather than envsubst: sed is part of busybox and so is +# guaranteed present, whereas envsubst comes from gettext and is not something +# we can rely on being installed in the base image. A missing binary here would +# crash-loop the container on every start. +# +# Escape the characters sed treats specially in a replacement -- a backslash, an +# `&` (which would expand to the whole match), and the `|` delimiter -- so that +# an API key containing any of them still renders correctly. +escaped_query=$(printf '%s' "$CARTO_TILE_QUERY" | sed -e 's/[\\&|]/\\&/g') +sed 's|${CARTO_TILE_QUERY}|'"$escaped_query"'|g' \ < /etc/nginx/templates/default.conf.template \ > /etc/nginx/conf.d/default.conf chmod 600 /etc/nginx/conf.d/default.conf From 95d81bf7853d962658fa062ecccdf3c71322cb24 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 03:27:45 +0000 Subject: [PATCH 3/3] chore(release): bump version to 1.17.1 Patch release for the map-center, CARTO basemap key, and documentation fixes in this branch -- no new user-facing features, consistent with the 1.16.1 patch release that preceded it. - package.json / package-lock.json: 1.17.0 -> 1.17.1 - CHANGELOG.md: [Unreleased] -> [1.17.1] - 2026-09-13 - RELEASE_NOTES.md: rewritten for 1.17.1, following the 1.16.1 patch-release format - README.md: version header updated to match Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UqP5vFS3rXNwGUgAvtRQt6 --- CHANGELOG.md | 3 ++- README.md | 2 +- RELEASE_NOTES.md | 24 ++++++++++-------------- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 16 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1d26e8..14bf22a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.17.1] - 2026-09-13 ### Added @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Map center environment variables had no effect** ([#23](https://github.com/d3mocide/MeshRF/issues/23)): `MapContainer` hardcoded Portland, OR and never read `VITE_MAP_LAT` / `VITE_MAP_LNG`. Two separate faults were involved — the variables were unused in the source, and `VITE_`-prefixed variables are inlined by Vite at *build* time, so setting them in `docker-compose.yml` could never reach the prebuilt image regardless. The initial view now resolves through `runtimeConfig`, and `docker-entrypoint.sh` writes `MAP_LAT` / `MAP_LNG` / `MAP_ZOOM` into `env-config.js` at container start. `VITE_MAP_LAT` / `VITE_MAP_LNG` are still accepted as deprecated aliases. Invalid or out-of-range values now warn and fall back to the default instead of handing Leaflet a `NaN`. - `public/env-config.js` no longer ships populated defaults. Because `window._env_` takes priority over `import.meta.env`, its baked-in values silently shadowed the `VITE_*` variables during `npm run dev` — part of why the map-center settings appeared to do nothing. - `vite preview` had no proxy configuration, so a built app served through it lost both `/api` and basemap proxying. Both servers now share one proxy definition. +- `docker-entrypoint.sh` rendered `nginx.conf` with `envsubst`, which comes from gettext and is not guaranteed to be present in the nginx base image — a missing binary would crash-loop the container on every start. Rendering now uses `sed`, which is part of busybox, with the substitution escaped so an API key containing `&`, `|` or `\` still renders correctly. ### Changed diff --git a/README.md b/README.md index 799e232..8ab39a0 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# meshRF 📡 v1.17.0 +# meshRF 📡 v1.17.1 A professional-grade RF propagation and link analysis tool designed for LoRa Mesh networks (Meshtastic, Reticulum, Sidewinder). Built with **React**, **Leaflet**, and a high-fidelity physics core combining a **Python Geodetic Engine** with **High-Performance WASM Modules**. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 07d3534..7002e92 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,27 +1,23 @@ -# MeshRF v1.17.0 - Propagation Model Expansion +# MeshRF v1.17.1 - Map Config & Basemap Key Patch -**Release Date**: August 6, 2026 -**Type**: Minor Release (New Features) -**Focus**: Rounding out the propagation model roadmap -- per-node coverage visualization, client-side Hata/FSPL, COST 231, WASM ITM batch reports, per-node CSV configs, and selectable ITM reliability modes. +**Release Date**: September 13, 2026 +**Type**: Patch Release (Bug Fixes) +**Focus**: Fixing the map center configuration variables, restoring CARTO basemap tiles now that CARTO requires an API key, and realigning `.env.example`, `README.md` and `Documentation/` with the current codebase. --- ## 🎯 Overview -This release closes out several roadmap items (P6-1, P3-1, P3-3, P3-4, P4-2, P4-6): +This patch release fixes three configuration bugs reported against the deployed image, plus a broad documentation cleanup: -- **Per-Node Coverage Visualization**: Multi-Site Analysis now renders each node's coverage in a distinct color instead of one flat composite mask. -- **Client-Side Hata & FSPL**: The Link Analysis tool resolves `fspl` and `hata` locally in the browser, so both work fully offline/PWA with no backend round-trip. -- **COST 231-Hata Extension**: Hata coverage now spans 150-2000 MHz (previously capped at 1500 MHz). -- **WASM ITM for Batch Reports**: Batch Processing can run the same Longley-Rice WASM engine used by Link Analysis over a 100-point terrain profile. -- **Per-Node Configs in Batch CSV**: CSV import accepts optional per-node antenna height, gain, TX power, device and antenna columns. -- **Reliability (Variability) Modes**: ITM's time/location/situation variability is now user-selectable (Best Case / Typical / Reliable) instead of hardcoded to 50/50/50. Requires a `libmeshrf` WASM rebuild, included in this release. - -Also includes a new CI workflow (frontend + rf-engine tests on every push/PR), a lint config fix that surfaced ~100 previously-masked warnings (all now resolved), and dependency audit fixes. +- **Map Center Fix** ([#23](https://github.com/d3mocide/MeshRF/issues/23)): `MAP_LAT` / `MAP_LNG` / `MAP_ZOOM` now actually move the initial map view. Two faults were stacked — the variables were unused in the source, and the `VITE_`-prefixed names used previously are inlined by Vite at *build* time, so setting them in `docker-compose.yml` could never reach the prebuilt image regardless. `VITE_MAP_LAT` / `VITE_MAP_LNG` still work as deprecated aliases. +- **CARTO Basemap API Key**: CARTO now requires a key for its raster basemaps or tiles render with an "API KEY REQUIRED" watermark. `CARTO_API_KEY` is applied **server-side only** — Nginx (production) or the Vite dev/preview server (development) appends it to proxied tile requests, so it is never present in the JS bundle or visible in devtools. Get a free key at [carto.com/basemaps/apikey](https://carto.com/basemaps/apikey/). +- **`.env.example` Realigned**: previously documented only two elevation variables; now covers every setting the stack actually reads, and Compose substitutes from it so one `.env` drives both the production and development stacks. +- **Documentation Audit**: `README.md`'s version and configuration table were out of date, `Documentation/README.md` linked a guide that never existed, and several tool guides predated features already shipped in 1.17.0. See [CHANGELOG.md](CHANGELOG.md) for the full list. See [CHANGELOG.md](CHANGELOG.md) for the full list of changes. --- **Full Changelog**: [CHANGELOG.md](CHANGELOG.md) -**Previous Release**: [v1.16.1](https://github.com/d3mocide/MeshRF/releases/tag/v1.16.1) +**Previous Release**: [v1.17.0](https://github.com/d3mocide/MeshRF/releases/tag/v1.17.0) diff --git a/package-lock.json b/package-lock.json index cd8c136..7112ee0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meshrf", - "version": "1.17.0", + "version": "1.17.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meshrf", - "version": "1.17.0", + "version": "1.17.1", "dependencies": { "@deck.gl-community/leaflet": "^9.2.0-beta.3", "@deck.gl/core": "^9.2.5", diff --git a/package.json b/package.json index 155660c..5de9c82 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "meshrf", "private": true, - "version": "1.17.0", + "version": "1.17.1", "type": "module", "scripts": { "dev": "vite",