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..14bf22a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.17.1] - 2026-09-13
+
+### 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.
+- `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
+
+- **`.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..8ab39a0 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
-# meshRF 📡 v1.16.1
+# 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**.
-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.

@@ -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/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/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..23c55e8 100644
--- a/docker-entrypoint.sh
+++ b/docker-entrypoint.sh
@@ -1,18 +1,75 @@
#!/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
+
+# 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
+
+# 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
# 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/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",
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",
+ }
}
})