From ebd500981dd4e184a5b5dcb1bc98d22928bec5c3 Mon Sep 17 00:00:00 2001
From: l5y <220195275+l5yth@users.noreply.github.com>
Date: Sat, 18 Jul 2026 11:58:05 +0200
Subject: [PATCH] web: HOT primary basemap (dark-filtered) with per-tile CARTO
fallback (#844)
---
ACCEPTANCE.md | 132 ++++++
SPEC.md | 50 ++-
.../js/app/__tests__/basemap-config.test.js | 105 +++++
.../js/app/__tests__/federation-page.test.js | 27 ++
.../js/app/__tests__/main-app-leaflet-stub.js | 38 ++
web/public/assets/js/app/basemap-config.js | 121 +++++-
web/public/assets/js/app/federation-page.js | 6 +-
web/public/assets/js/app/main.js | 20 +-
.../__tests__/fallback-tile-layer.test.js | 385 ++++++++++++++++++
.../app/main/__tests__/tile-test-helpers.js | 166 ++++++++
.../assets/js/app/main/fallback-tile-layer.js | 259 ++++++++++++
web/public/assets/styles/base.css | 19 +
12 files changed, 1296 insertions(+), 32 deletions(-)
create mode 100644 web/public/assets/js/app/__tests__/basemap-config.test.js
create mode 100644 web/public/assets/js/app/main/__tests__/fallback-tile-layer.test.js
create mode 100644 web/public/assets/js/app/main/__tests__/tile-test-helpers.js
create mode 100644 web/public/assets/js/app/main/fallback-tile-layer.js
diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md
index 132db70..f0d13a1 100644
--- a/ACCEPTANCE.md
+++ b/ACCEPTANCE.md
@@ -2129,6 +2129,11 @@ in `web/lib/potato_mesh/config.rb`,
shell checks from the repo root.
### DM-A1 — Both maps use CARTO Dark Matter; HOT is gone — DM1
+
+> **⚠️ Superseded by HT-A1** (§ *HOT primary basemap (dark-filtered) with per-tile
+> CARTO fallback*). HOT is intentionally restored as the **primary** basemap, so
+> the "HOT is gone" expectation below no longer holds by design; CARTO is retained
+> as the per-tile fallback. **HT-A1 is the authoritative check.**
```bash
git grep -nE "basemaps\.cartocdn\.com/dark_all" -- web/public/assets/js
git grep -niE "openstreetmap\.fr|/hot/" -- web/public/assets/js web/lib web/views
@@ -2141,6 +2146,13 @@ layer options (subdomains `abcd`, `detectRetina`, `crossOrigin:'anonymous'`,
`maxZoom`) are asserted by the JS map-init / DM-A3 suite.
### DM-A2 — Tile-filter pipeline fully removed (native dark) — DM2
+
+> **⚠️ Partially superseded by HT-A2.** A single **static** dark filter is
+> intentionally reintroduced for HOT tiles (CSS/JS constant only). The Ruby
+> `tile_filters` / `data-app-config` `tileFilters` half of this check **still
+> holds** (that plumbing stays removed), and none of the removed per-theme
+> machinery (`resolveTileFilter` / `applyFiltersToAllTiles` / MutationObserver /
+> `--map-tile*-filter`) returns. **HT-A2 is the authoritative check.**
```bash
git grep -niE "tile_filters|DEFAULT_TILE_FILTER|map_tile_filter|tileFilters|map-tile-filter|map-tiles-filter|resolveTileFilter|applyTileFilter|applyFiltersToAllTiles|applyFilterToTile|ensureTileHasCurrentFilter" -- web/lib web/public/assets web/views
git grep -n -A2 "def resolve_initial_theme" -- web/lib/potato_mesh/application/routes/root.rb
@@ -2651,3 +2663,123 @@ nothing (B4 intact). No source code, dependency manifest, or test suite is
touched by this fix — B1 suites are unaffected by construction; the only
behavioral deltas are matrix cancellation policy and armv7 build-stage
packages.
+
+---
+
+## Feature: HOT primary basemap (dark-filtered) with per-tile CARTO fallback
+
+Maps to SPEC decisions **HT1–HT8**. The shared basemap factory lives in
+`web/public/assets/js/app/basemap-config.js`; the per-tile timeout→CARTO tile
+layer in `web/public/assets/js/app/main/fallback-tile-layer.js`; the dashboard
+wiring in `web/public/assets/js/app/main.js` and federation wiring in
+`web/public/assets/js/app/federation-page.js`; the static dark filter in
+`web/public/assets/styles/base.css`; the offline last-resort tier in
+`web/public/assets/js/app/main/offline-tile-layer.js` (dashboard only). Run JS
+checks from `web/`, shell checks from the repo root.
+
+### HT-A1 — HOT is the primary basemap on both maps; CARTO retained as fallback — HT1
+```bash
+git grep -nE "tile\.openstreetmap\.fr/hot" -- web/public/assets/js
+git grep -nE "basemaps\.cartocdn\.com/dark_all" -- web/public/assets/js
+```
+**Expected:** the first prints the HOT URL
+(`{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png`) from **one** shared basemap
+module (`basemap-config.js`) referenced by both the dashboard and federation maps;
+the second still prints the CARTO Dark Matter URL
+(`{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png`) — **retained**, now as
+the per-tile fallback source, not the primary. HOT options (`subdomains:'abc'`,
+`maxZoom:19`, `crossOrigin:'anonymous'`) and CARTO options (`subdomains:'abcd'`,
+`detectRetina`, `crossOrigin:'anonymous'`) are asserted by the HT-A3/HT-A5 suites.
+**Supersedes DM-A1** (which required the HOT reference to be absent).
+
+### HT-A2 — Dark filter reintroduced for HOT only; static, dark-only, off the contract — HT2
+```bash
+git grep -nE "grayscale\(1\) invert\(1\)" -- web/public/assets/styles/base.css
+git grep -niE "tile_filters|DEFAULT_TILE_FILTER|map_tile_filter|tileFilters|resolveTileFilter|applyFiltersToAllTiles|--map-tile" -- web/lib web/public/assets/js web/public/assets/styles web/views
+git grep -n -A2 "def resolve_initial_theme" -- web/lib/potato_mesh/application/routes/root.rb
+```
+**Expected:** the first prints the reintroduced dark filter
+(`grayscale(1) invert(1) brightness(0.9) contrast(1.08)`) as a **static** rule on
+the per-tile class `.map-tiles-hot` in `base.css` (Leaflet puts a layer's
+`className` on the tile container, not each tile, so per-tile filtering uses a
+per-tile class). The second prints **nothing** — none of the removed
+per-theme machinery returns: no Ruby `tile_filters`/`DEFAULT_TILE_FILTER_*`, no
+`data-app-config` `tileFilters`, no JS `resolveTileFilter`/`applyFiltersToAllTiles`,
+and no `--map-tile*-filter` custom property. The filter is one static CSS rule
+(HOT-only; CARTO-fallback tiles carry `.map-tiles-fallback { filter: none }`); the
+third shows `resolve_initial_theme` still returns `"dark"` (app stays dark-only, so
+no light filter exists). **Supersedes the CSS/JS half of DM-A2**; the Ruby/contract
+half of DM-A2 still holds.
+
+### HT-A3 — Per-tile 1000 ms timeout swaps HOT→CARTO — HT3
+```bash
+( cd web && node --test public/assets/js/app/main/__tests__/fallback-tile-layer.test.js )
+```
+**Expected:** pass. The Leaflet-free fallback logic decides: (a) a tile whose HOT
+image loads before 1000 ms keeps the HOT source (filtered) and cancels its timer;
+(b) a tile whose HOT image fires `error` is swapped to the CARTO URL for the same
+`{z}/{x}/{y}` immediately; (c) a tile whose HOT image neither loads nor errors
+within 1000 ms is swapped to CARTO on timeout; (d) a swapped tile is marked
+`.map-tiles-fallback` (unfiltered) and requests the CARTO subdomain/retina URL.
+The 1000 ms threshold is a single named constant (the source of truth).
+
+### HT-A4 — Offline placeholder only when BOTH providers fail (dashboard) — HT4
+```bash
+( cd web && node --test public/assets/js/app/main/__tests__/fallback-tile-layer.test.js \
+ public/assets/js/app/main/__tests__/tile-failure-policy.test.js )
+```
+**Expected:** pass. The fallback layer signals Leaflet `tileload` when **either**
+HOT or the CARTO fallback serves a tile, and signals `tileerror` **only** when the
+CARTO fallback tile *also* fails (covered by `fallback-tile-layer.test.js`). The
+DM3 `tile-failure-policy` is unchanged and stays green: the offline `GridLayer`
+(`main/offline-tile-layer.js`) activates only on comprehensive both-provider
+failure (zero successful loads across the initial viewport), preserving DM-A3
+tolerance one tier lower. The federation map has no offline tier (unchanged from
+DM3).
+
+### HT-A5 — Both maps use the one shared basemap factory — HT5
+```bash
+git grep -nE "createBasemapLayer" -- web/public/assets/js/app/basemap-config.js web/public/assets/js/app/main.js web/public/assets/js/app/federation-page.js
+git grep -nE "createOfflineTileLayer|activateOfflineTiles" -- web/public/assets/js/app/federation-page.js
+```
+**Expected:** the first shows `createBasemapLayer` **defined once** in
+`basemap-config.js` and **called by both** `main.js` and `federation-page.js` — one
+basemap definition, both maps identical (HOT-primary + CARTO fallback). The second
+prints **nothing** — the offline GridLayer tier is dashboard-only (federation gains
+no kill-basemap/offline logic, per DM3).
+
+### HT-A6 — No attribution overlay (reaffirms DM5) — HT6
+```bash
+git grep -nE "attributionControl:\s*false" -- web/public/assets/js
+git grep -nE "\battribution:" -- web/public/assets/js/app/main.js web/public/assets/js/app/federation-page.js web/public/assets/js/app/basemap-config.js
+```
+**Expected:** the first prints `attributionControl: false` on **both** maps
+(unchanged from DM-A5); the second prints **nothing** — no `attribution:` credit
+string was added for HOT or CARTO.
+
+### HT-A7 — Apex/contract untouched — HT7
+```bash
+git grep -niE 'mqtt|mosquitto|paho|amqp|kafka|broker' -- web/public/assets/js/app/basemap-config.js web/public/assets/js/app/main/fallback-tile-layer.js web/public/assets/js/app/main.js web/public/assets/js/app/federation-page.js
+git grep -nE "tileFilters" -- web/lib/potato_mesh/application/helpers/config_helpers.rb
+git diff --name-only HEAD -- web/Gemfile web/package.json data/requirements.txt matrix/Cargo.toml app/pubspec.yaml
+```
+**Expected:** the first two print **nothing** — the basemap hosts are not brokers
+(apex **A1** stays green) and `frontend_app_config` emits no `tileFilters` (no
+`/version` / `data-app-config` contract move). The third prints **nothing** — no
+dependency manifest changed, so `guard-edits.py` never triggers and the frozen
+stack (**D6**) is unaffected.
+
+### HT-R1 — Regression: prior acceptance still holds
+```bash
+( cd web && npm test ) && ( cd web && bundle exec rspec )
+```
+**Expected:** every prior check still passes. Explicitly amended and required to
+stay green: **DM-A1** (superseded by HT-A1 — HOT is intentionally back), **DM-A2**
+(CSS/JS half superseded by HT-A2 — the static dark filter is intentionally back;
+the Ruby/contract half still holds), **DM-A3** (extended by HT-A4 — tolerance
+preserved behind the CARTO tier). Still green unchanged: **DM-A5 / DM-A6 / DM-A7**,
+**A1** (apex — no broker), **B1** (all suites), **B4** (exact Apache header on the
+new `main/fallback-tile-layer.js` and its test), and **D1 / BF1** (the `/version`
+config block is unchanged). The DM-era JS tests are **updated** to the HOT-primary
++ CARTO-fallback wiring, not removed: `__tests__/config.test.js`,
+`__tests__/federation-page.test.js`, and the leaflet-stub map-init harness.
diff --git a/SPEC.md b/SPEC.md
index b7d6623..204b6f0 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -544,11 +544,57 @@ contradicted; the change is a net simplification.
| # | Decision | Source |
| --- | --- | --- |
-| **DM1** | **Provider swap to CARTO Dark Matter.** The single, de-duplicated tile-URL constant becomes `https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png` (subdomains `abcd`, `detectRetina` for the `{r}` HiDPI suffix), replacing `https://{s}.tile.openstreetmap.fr/hot/…` on **both** the dashboard map (`main.js`) and the federation map (`federation-page.js`). Keyless public CDN; returns `access-control-allow-origin: *` so the existing `crossOrigin:'anonymous'` is satisfied; natively dark-grey, so "keep the dark/grey style" holds with no filter. | interview + probe |
-| **DM2** | **Native dark style; the CSS tile-filter pipeline is removed.** Dark Matter is already styled, so the per-theme `grayscale/invert` filter (which existed only to grey out the colourful HOT tiles and is the last **light-theme** remnant) is deleted end-to-end: Ruby `DEFAULT_TILE_FILTER_LIGHT`/`DEFAULT_TILE_FILTER_DARK`, `map_tile_filter_light`/`map_tile_filter_dark`, `tile_filters`, and the `tileFilters` key in `frontend_app_config` (`config_helpers.rb`); JS `TILE_FILTER_LIGHT`/`TILE_FILTER_DARK`, `resolveTileFilter` + the tile filter-application/MutationObserver machinery in `main.js`, `applyTileFilter`/`resolveTheme`/`themechange` in `federation-page.js`, `tileFilters` in `settings.js`, and the `window.applyFiltersToAllTiles` hook in `theme.js`; CSS `--map-tile-filter-light`, `--map-tiles-filter`, and the `.map-tiles { filter: … }` rules in `base.css`. The broader theme system is already dark-only (`resolve_initial_theme` returns `"dark"`). | interview |
+| **DM1** | **Provider swap to CARTO Dark Matter.** The single, de-duplicated tile-URL constant becomes `https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png` (subdomains `abcd`, `detectRetina` for the `{r}` HiDPI suffix), replacing `https://{s}.tile.openstreetmap.fr/hot/…` on **both** the dashboard map (`main.js`) and the federation map (`federation-page.js`). Keyless public CDN; returns `access-control-allow-origin: *` so the existing `crossOrigin:'anonymous'` is satisfied; natively dark-grey, so "keep the dark/grey style" holds with no filter. **Superseded by HT1** — HOT is restored as the primary basemap (dark-filtered) and CARTO Dark Matter is retained only as a per-tile fallback, not the sole provider. | interview + probe |
+| **DM2** | **Native dark style; the CSS tile-filter pipeline is removed.** Dark Matter is already styled, so the per-theme `grayscale/invert` filter (which existed only to grey out the colourful HOT tiles and is the last **light-theme** remnant) is deleted end-to-end: Ruby `DEFAULT_TILE_FILTER_LIGHT`/`DEFAULT_TILE_FILTER_DARK`, `map_tile_filter_light`/`map_tile_filter_dark`, `tile_filters`, and the `tileFilters` key in `frontend_app_config` (`config_helpers.rb`); JS `TILE_FILTER_LIGHT`/`TILE_FILTER_DARK`, `resolveTileFilter` + the tile filter-application/MutationObserver machinery in `main.js`, `applyTileFilter`/`resolveTheme`/`themechange` in `federation-page.js`, `tileFilters` in `settings.js`, and the `window.applyFiltersToAllTiles` hook in `theme.js`; CSS `--map-tile-filter-light`, `--map-tiles-filter`, and the `.map-tiles { filter: … }` rules in `base.css`. The broader theme system is already dark-only (`resolve_initial_theme` returns `"dark"`). **Partially superseded by HT2** — a dark tile filter is reintroduced as a frontend-only static CSS constant applied to HOT tiles (CARTO fallback tiles stay unfiltered); the Ruby `tile_filters` / `data-app-config` `tileFilters` plumbing removed here stays removed. | interview |
| **DM3** | **Tolerate isolated tile errors (dashboard).** The first `tileerror` no longer flips the whole map to the offline placeholder. Isolated tile failures remain individual blank tiles (Leaflet default); `activateOfflineTiles` fires **only** when the basemap is comprehensively unavailable — **zero** successful tile loads across the initial viewport (provider unreachable/blocked). After **any** successful `tileload` the basemap is latched "alive" and subsequent isolated `tileerror`s never trigger the offline switch; recovery is automatic on later loads. The offline `GridLayer` (`main/offline-tile-layer.js`) is retained as the last-resort fallback. The federation map has no kill-basemap logic today, so it receives only DM1+DM2. | interview |
| **DM4** | **Adjacent light remnants removed.** `` → `content="dark"` (`views/layouts/app.erb`); `background.js`'s `body.classList.contains('dark') ? '#0e1418' : '#f6f3ee'` ternary collapses to the dark colour `'#0e1418'`. (The broader dead light **CSS palette** is handled separately by DM7.) | interview |
| **DM5** | **No attribution overlay (keep the clean look).** The map keeps `attributionControl:false` as today; no `© OpenStreetMap / © CARTO` credit is rendered. Accepted trade-off: this under-attributes CARTO/OSM — the same posture already taken with the HOT tiles — and is chosen to preserve the existing clean map style. | interview |
| **DM6** | **Engineering bar & invariants (D9).** No server-side broker/dependency/egress; no `/api/*` or `/version` contract change; protocol-neutral. Every changed JS/Ruby unit ships with 100% unit tests (existing tile-filter specs are **updated or removed-as-dead**, never left dangling), JSDoc/RDoc, the exact Apache header, and `rufo`/`black`-clean formatting; all existing suites stay green. | D9 + proposed |
| **DM7** | **Dead light CSS palette collapsed (dark-only).** `base.css` carried a full light token palette in `:root`, always overridden by an always-applied `body.dark { … }` token block, plus `html { color-scheme: light }`. Since `body` is always `dark` (`resolve_initial_theme` is fixed `"dark"`), the light half never rendered. The dual palette is collapsed to a **single dark `:root`** (the former `body.dark` token values promoted up, so `html` itself resolves dark tokens too), the `body.dark` *token* block and the light/`data-theme` `color-scheme` rules are removed, and `html { color-scheme: dark }`. `body.dark` *component* rules are untouched (still apply, `body` always has the class), so the rendered dark appearance is unchanged — verified by screenshot. | interview (scope expansion, approved) |
+---
+
+## Feature: HOT primary basemap (dark-filtered) with per-tile CARTO fallback
+
+Reinstates the OpenStreetMap France **HOT** (Humanitarian OSM Team) tile server as
+the **primary** basemap on both maps, dark-styled by the reintroduced
+`grayscale/invert` CSS filter, and demotes **CARTO Dark Matter** (the DM1 provider)
+to a **per-tile fallback**: any HOT tile that errors or fails to load within
+**1000 ms** is individually replaced by the CARTO tile at the same coordinate.
+CARTO Dark Matter proved reliable but too dark for the default look; HOT
+(dark-filtered) is the desired style, with CARTO as the dependable safety net and
+the offline placeholder (DM3) behind both. **Deliberately amends DM1 and DM2** (the
+sole-provider swap and the end-to-end filter deletion) while keeping their
+invariant posture (no attribution, dark-only theme, no broker, no contract change).
+Frontend-only (vanilla JS, `base.css`); no API/DB/ingestor change and no Ruby
+`tile_filters` / `data-app-config` return.
+
+**Conflict check.** *DM1 (CARTO sole basemap)* — **contradicts → amended** (HT1:
+HOT primary, CARTO fallback). *DM2 (filter pipeline deleted)* — **contradicts →
+amended** (HT2: dark filter reintroduced as a frontend-only static constant,
+HOT-only). *DM3 (offline placeholder)* — **extends** (offline is now the last tier,
+reached only when both HOT and CARTO fail). *DM4 / DM7 (dark-only)* —
+**consistent** (stays dark-only; the light filter is dropped as dead code).
+*DM5 (no attribution)* — **reaffirmed**. *Apex I* — **consistent**: HOT and CARTO
+are raster tile CDNs, not MQTT/cloud brokers; no manifest/dependency change
+(`guard-edits.py` untriggered); tiles are browser→CDN as before. *Invariant II /
+D11 (no phone-home)* — **consistent**: both providers are keyless and cookieless,
+and CARTO is fetched only for a HOT tile that failed, so common-case egress is
+HOT-only — no new per-operator credential and no telemetry. *Invariant IV
+(parity)* — **consistent**: the basemap is protocol-neutral, identical for
+Meshtastic and MeshCore. *D7 (fixed stack)* — **consistent**: native Leaflet
+URL/layer config, no new package or build step. *D8 (stable contract)* —
+**consistent**: the filter is a frontend constant; nothing enters `/version` or any
+`/api/*` shape, so no version bump. No invariant is contradicted.
+
+| # | Decision | Source |
+| --- | --- | --- |
+| **HT1** | **HOT primary; CARTO retained as fallback (amends DM1).** The shared basemap's **primary** layer is OpenStreetMap France HOT — `https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png` (`subdomains:'abc'`, `maxZoom:19`, `crossOrigin:'anonymous'`, `className:'map-tiles'`) — restored on **both** the dashboard (`main.js`) and federation (`federation-page.js`) maps. CARTO Dark Matter (`https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png`, `subdomains:'abcd'`, `detectRetina:true`, `crossOrigin:'anonymous'`) is **kept** but only as the per-tile fallback source (HT3), never the primary. HOT already ran with `crossOrigin:'anonymous'` before #831 (it serves permissive CORS), so the headless OG-image capture stays untainted. **Amends DM1** (CARTO-as-sole-provider). | interview |
+| **HT2** | **Dark filter for HOT only; frontend constant, dark-only (amends DM2).** The `grayscale(1) invert(1) brightness(0.9) contrast(1.08)` filter is reintroduced **only** as a static CSS rule on the per-tile class `.map-tiles-hot`, styling the natively-colourful HOT tiles dark. (Leaflet stamps a layer's `className` on the tile *container*, not each `
`, so per-tile HOT/CARTO filtering needs a per-tile class; the container keeps its unchanged `map-tiles` tag for the tile-pane opacity.) **CARTO fallback tiles are exempt** — a swapped tile drops `.map-tiles-hot` for `.map-tiles-fallback` (`filter:none`), because CARTO is already dark and `invert(1)` would render it light; offline placeholder tiles carry neither class and stay unfiltered too. **No light-theme filter** is added (the app is dark-only per DM7 — a light variant would be dead code). The removed per-theme machinery is **not** restored: no Ruby `tile_filters`/`DEFAULT_TILE_FILTER_*`, no `frontend_app_config`/`data-app-config` `tileFilters`, no JS `resolveTileFilter`/`applyFiltersToAllTiles`/MutationObserver, no `--map-tile*-filter` custom property. The filter lives entirely in one static `base.css` rule (D8 contract untouched). **Amends DM2** (which deleted the filter end-to-end). | interview |
+| **HT3** | **Per-tile 1000 ms timeout → CARTO (the core mechanism).** A custom Leaflet `TileLayer` (built by the shared factory) overrides tile creation: for each tile it sets the HOT `src` and starts a **1000 ms** timer (a single named constant, the source of truth). If the HOT image fires `error` **or** the timer elapses before `load`, the tile's `src` is swapped to the CARTO URL for the same `{z}/{x}/{y}` and the `map-tiles-fallback` marker is applied (HT2). A successful HOT `load` clears the timer and keeps the filtered HOT tile. The timeout value and the swap-decision/URL logic are pure and Leaflet-free for standalone unit testing (mirroring `main/tile-failure-policy.js`); the thin `createTile` wiring is covered via the leaflet-stub harness. | interview |
+| **HT4** | **Fallback ladder; offline placeholder is the last tier (extends DM3).** Leaflet's `tileload` reaches the DM3 `tile-failure-policy` when **either** HOT or the CARTO fallback serves a tile (the basemap is "alive" if either provider works); `tileerror` is signalled **only** when the CARTO fallback tile *also* fails. Thus an isolated HOT failure that CARTO covers never trips the offline switch, and `activateOfflineTiles` (`main/offline-tile-layer.js`) fires only when tiles fail comprehensively on **both** providers — preserving DM3/DM-A3 tolerance one tier lower. The offline `GridLayer` tier stays **dashboard-only**, exactly as DM3 scoped it. | interview |
+| **HT5** | **Both maps share one basemap factory (parity, no duplication).** A single `createBasemapLayer(L)` in the shared basemap module (`basemap-config.js`) builds the HOT-primary / per-tile-CARTO-fallback layer and is called by **both** `main.js` and `federation-page.js` — preserving DM-A1's "one shared basemap definition referenced by both maps." Both maps render HOT (dark-filtered) with the identical CARTO fallback. The federation map keeps **no** kill-basemap/offline logic (unchanged from DM3): a tile that fails on both providers simply stays blank there. Protocol-neutral (Invariant IV). | interview |
+| **HT6** | **No attribution (reaffirms DM5).** Both maps keep `attributionControl:false`; no `© OpenStreetMap / © CARTO` credit is rendered. Accepted under-attribution trade-off — now spanning both providers — chosen to preserve the clean map style, unchanged from DM5. | interview |
+| **HT7** | **Apex / privacy / stack / contract all untouched.** HOT and CARTO are raster tile CDNs, not MQTT/cloud brokers — the apex (I) holds and `guard-edits.py` is untriggered (no manifest/dependency change). Both are keyless and cookieless, and CARTO is requested only for a HOT tile that failed, so the common case egresses to HOT alone — no new phone-home or per-operator credential (II / D11). Native Leaflet only, no new package or build step (D7). The dark filter is a frontend CSS constant and never enters `/version`, `data-app-config`, or any `/api/*` shape, so there is no contract change or version bump (D8). | proposed |
+| **HT8** | **Engineering bar (D9).** The new/changed frontend units — the shared `createBasemapLayer` factory, the per-tile timeout/fallback tile layer, and its pure timeout/URL helpers — ship with **100% unit tests**, full JSDoc, the exact Apache header, and clean linters; all existing suites stay green. The DM-era tile tests are **updated**, not left dangling: `__tests__/config.test.js`, `__tests__/federation-page.test.js`, the leaflet-stub map-init harness, and `main/__tests__/tile-failure-policy.test.js` are retargeted to the HOT-primary + CARTO-fallback wiring. | D9 + proposed |
+
diff --git a/web/public/assets/js/app/__tests__/basemap-config.test.js b/web/public/assets/js/app/__tests__/basemap-config.test.js
new file mode 100644
index 0000000..71ef9fe
--- /dev/null
+++ b/web/public/assets/js/app/__tests__/basemap-config.test.js
@@ -0,0 +1,105 @@
+/*
+ * Copyright © 2025-26 l5yth & contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ HOT_TILE_URL,
+ HOT_TILE_OPTIONS,
+ CARTO_TILE_URL,
+ CARTO_TILE_OPTIONS,
+ FALLBACK_TIMEOUT_MS,
+ prefersRetinaTiles,
+ createBasemapLayer,
+} from '../basemap-config.js';
+import {
+ HOT_TILE_CLASS,
+ FALLBACK_TILE_CLASS,
+} from '../main/fallback-tile-layer.js';
+import {
+ makeLeafletTileLayerStub,
+ withImgDocument,
+} from '../main/__tests__/tile-test-helpers.js';
+
+test('HOT is the primary tile source; CARTO is retained for the fallback', () => {
+ assert.match(HOT_TILE_URL, /tile\.openstreetmap\.fr\/hot/);
+ assert.equal(HOT_TILE_OPTIONS.subdomains, 'abc');
+ assert.equal(HOT_TILE_OPTIONS.crossOrigin, 'anonymous');
+ assert.equal(HOT_TILE_OPTIONS.maxZoom, 19);
+ assert.match(CARTO_TILE_URL, /basemaps\.cartocdn\.com\/dark_all/);
+ assert.equal(CARTO_TILE_OPTIONS.subdomains, 'abcd');
+ assert.equal(CARTO_TILE_OPTIONS.detectRetina, true);
+ assert.equal(FALLBACK_TIMEOUT_MS, 1000);
+});
+
+test('prefersRetinaTiles reflects the device pixel ratio', () => {
+ const previous = globalThis.devicePixelRatio;
+ try {
+ globalThis.devicePixelRatio = 2;
+ assert.equal(prefersRetinaTiles(), true);
+ globalThis.devicePixelRatio = 1;
+ assert.equal(prefersRetinaTiles(), false);
+ globalThis.devicePixelRatio = undefined;
+ assert.equal(prefersRetinaTiles(), false);
+ } finally {
+ globalThis.devicePixelRatio = previous;
+ }
+});
+
+test('createBasemapLayer returns null when Leaflet is unavailable', () => {
+ assert.equal(createBasemapLayer(null), null);
+});
+
+test('createBasemapLayer builds the HOT-primary layer with CARTO fallback wiring', () => {
+ const previous = globalThis.devicePixelRatio;
+ const doc = withImgDocument();
+ try {
+ globalThis.devicePixelRatio = 1; // non-retina fallback
+ const L = makeLeafletTileLayerStub();
+ const layer = createBasemapLayer(L);
+ assert.ok(layer);
+ assert.equal(layer._url, HOT_TILE_URL);
+ assert.equal(layer.options, HOT_TILE_OPTIONS);
+ const tile = layer.createTile({ x: 2, y: 1, z: 6 }, () => {});
+ assert.equal(tile.classList.contains(HOT_TILE_CLASS), true);
+ // (2 + 1) % 3 = 0 -> 'a' HOT
+ assert.equal(tile.src, 'https://a.tile.openstreetmap.fr/hot/6/2/1.png');
+ tile.dispatch('error'); // fall back
+ // (2 + 1) % 4 = 3 -> 'd' CARTO, non-retina
+ assert.equal(tile.src, 'https://d.basemaps.cartocdn.com/dark_all/6/2/1.png');
+ assert.equal(tile.classList.contains(FALLBACK_TILE_CLASS), true);
+ } finally {
+ globalThis.devicePixelRatio = previous;
+ doc.restore();
+ }
+});
+
+test('createBasemapLayer requests @2x CARTO fallback tiles on HiDPI displays', () => {
+ const previous = globalThis.devicePixelRatio;
+ const doc = withImgDocument();
+ try {
+ globalThis.devicePixelRatio = 3;
+ const L = makeLeafletTileLayerStub();
+ const layer = createBasemapLayer(L);
+ const tile = layer.createTile({ x: 0, y: 0, z: 2 }, () => {});
+ tile.dispatch('error');
+ assert.match(tile.src, /@2x\.png$/);
+ } finally {
+ globalThis.devicePixelRatio = previous;
+ doc.restore();
+ }
+});
diff --git a/web/public/assets/js/app/__tests__/federation-page.test.js b/web/public/assets/js/app/__tests__/federation-page.test.js
index 2f55d98..70c2b0b 100644
--- a/web/public/assets/js/app/__tests__/federation-page.test.js
+++ b/web/public/assets/js/app/__tests__/federation-page.test.js
@@ -45,6 +45,29 @@ function createBasicFederationPageHarness() {
return { ...env, statusEl, tbodyEl };
}
+/**
+ * Build a Leaflet ``TileLayer`` class stub with ``extend`` so the shared
+ * ``createBasemapLayer`` factory (which subclasses ``L.TileLayer`` for the
+ * per-tile HOT→CARTO fallback) resolves to a layer exposing ``addTo`` in the
+ * federation harness. ``createTile`` is never invoked because the map stub
+ * renders no tiles, so the subclass body is irrelevant.
+ *
+ * @returns {Function} Constructable ``TileLayer`` with a static ``extend``.
+ */
+function makeTileLayerClass() {
+ function TileLayer() {}
+ TileLayer.extend = function extend() {
+ return function BasemapLayer() {
+ return {
+ addTo() {
+ return this;
+ }
+ };
+ };
+ };
+ return TileLayer;
+}
+
function createBasicLeafletStub(options = {}) {
const { markerPopups = null, fitBounds = false } = options;
@@ -59,6 +82,7 @@ function createBasicLeafletStub(options = {}) {
}
};
},
+ TileLayer: makeTileLayerClass(),
tileLayer() {
return {
addTo() {
@@ -155,6 +179,7 @@ test('federation map centers on configured coordinates and follows theme filters
map() {
return mapStub;
},
+ TileLayer: makeTileLayerClass(),
tileLayer() {
return tileLayerStub;
},
@@ -366,6 +391,7 @@ test('federation table sorting, contact rendering, and legend creation', async (
map() {
return mapStub;
},
+ TileLayer: makeTileLayerClass(),
tileLayer() {
return {
addTo() {
@@ -598,6 +624,7 @@ test('federation legend toggle respects media query changes', async () => {
fitBounds() {}
};
},
+ TileLayer: makeTileLayerClass(),
tileLayer() {
return {
addTo() {
diff --git a/web/public/assets/js/app/__tests__/main-app-leaflet-stub.js b/web/public/assets/js/app/__tests__/main-app-leaflet-stub.js
index 4f6743c..1cece97 100644
--- a/web/public/assets/js/app/__tests__/main-app-leaflet-stub.js
+++ b/web/public/assets/js/app/__tests__/main-app-leaflet-stub.js
@@ -161,6 +161,43 @@ export function makeLeafletStub() {
return tile;
}
+ /**
+ * Build an ``L.TileLayer`` class stub supporting ``extend`` so the shared
+ * ``createBasemapLayer`` factory (which subclasses ``L.TileLayer`` to add the
+ * per-tile HOT→CARTO fallback) resolves in the harness. Instances reuse the
+ * ``makeTileLayer`` shape (``on`` / ``addTo`` / ``_events``); ``createTile`` is
+ * never invoked because the map stub does not render tiles.
+ *
+ * @returns {Function} Constructable ``TileLayer`` with a static ``extend``.
+ */
+ function makeTileLayerClass() {
+ /**
+ * @param {string} url Tile URL template.
+ * @param {Object} [options] Tile options.
+ */
+ function TileLayer(url, options) {
+ Object.assign(this, makeTileLayer(url, options));
+ }
+ /**
+ * @param {Object} proto Prototype members mixed into the subclass.
+ * @returns {Function} The subclass constructor.
+ */
+ TileLayer.extend = function extend(proto) {
+ /**
+ * @param {string} url Tile URL template.
+ * @param {Object} [options] Tile options.
+ */
+ function Sub(url, options) {
+ TileLayer.call(this, url, options);
+ }
+ Sub.prototype = Object.create(TileLayer.prototype);
+ Sub.prototype.constructor = Sub;
+ Object.assign(Sub.prototype, proto);
+ return Sub;
+ };
+ return TileLayer;
+ }
+
/**
* Construct the map stub returned by ``L.map()``. ``getZoom`` is
* mutable via ``_setZoom`` so individual tests can drive the dispatch
@@ -215,6 +252,7 @@ export function makeLeafletStub() {
return stub._map;
},
tileLayer: makeTileLayer,
+ TileLayer: makeTileLayerClass(),
layerGroup: makeLayerGroup,
circleMarker(latLng, options) {
const marker = makeMarker(latLng, options);
diff --git a/web/public/assets/js/app/basemap-config.js b/web/public/assets/js/app/basemap-config.js
index eb79fbd..d407515 100644
--- a/web/public/assets/js/app/basemap-config.js
+++ b/web/public/assets/js/app/basemap-config.js
@@ -18,46 +18,127 @@
* Shared basemap (map tile) configuration for every Leaflet map in the app.
*
* Both the dashboard map (``app/main.js``) and the federation map
- * (``app/federation-page.js``) render the same basemap, so the tile URL and
- * layer options live here once instead of being duplicated per page.
+ * (``app/federation-page.js``) render the same basemap through a single factory
+ * ({@link createBasemapLayer}), so the tile URLs, layer options, and fallback
+ * behaviour live here once instead of being duplicated per page.
*
- * The basemap is **CARTO Dark Matter** — a keyless, CORS-enabled, natively
- * dark-grey raster basemap. Because the tiles are already dark-grey there is no
- * CSS colour filter: the previous ``grayscale``/``invert`` pipeline (a remnant
- * of the removed light theme) was deleted along with this swap.
+ * The **primary** basemap is OpenStreetMap France **HOT** (Humanitarian OSM
+ * Team) — a natively colourful raster basemap greyed to match the dark UI by the
+ * static ``grayscale``/``invert`` CSS filter on ``.map-tiles-hot``. **CARTO Dark
+ * Matter** (natively dark-grey) is retained as a **per-tile fallback**: any HOT
+ * tile that errors or fails to load within {@link FALLBACK_TIMEOUT_MS} is
+ * individually replaced by the CARTO tile at the same coordinate (see
+ * ``main/fallback-tile-layer.js``). Both providers are keyless, CORS-enabled
+ * public CDNs.
*
* @module app/basemap-config
*/
+import { createFallbackTileLayer } from './main/fallback-tile-layer.js';
+
/**
- * Tile URL template for the CARTO Dark Matter basemap.
+ * Tile URL template for the primary OpenStreetMap France HOT basemap.
*
- * ``{s}`` rotates over the ``abcd`` subdomains, ``{r}`` expands to ``@2x`` on
- * HiDPI displays (via ``detectRetina``) for crisp tiles, and ``{z}/{x}/{y}`` is
- * the standard slippy-map tile coordinate.
+ * ``{s}`` rotates over the ``abc`` subdomains and ``{z}/{x}/{y}`` is the standard
+ * slippy-map tile coordinate. HOT serves no ``@2x`` retina variant, so the
+ * template carries no ``{r}`` suffix.
*
* @type {string}
*/
-export const TILE_LAYER_URL =
+export const HOT_TILE_URL = 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png';
+
+/**
+ * Leaflet ``tileLayer`` options for the primary HOT layer.
+ *
+ * - ``maxZoom`` caps zoom at the basemap's supported detail level.
+ * - ``className`` tags the tile *container* for the existing tile-pane opacity.
+ * - ``crossOrigin`` requests tiles with CORS so the canvas-based OG-image capture
+ * and any pixel reads stay untainted (HOT serves ``access-control-allow-origin``).
+ * - ``subdomains`` spreads requests across HOT's three tile hosts (``a``/``b``/``c``).
+ *
+ * @type {{maxZoom: number, className: string, crossOrigin: string, subdomains: string}}
+ */
+export const HOT_TILE_OPTIONS = {
+ maxZoom: 19,
+ className: 'map-tiles',
+ crossOrigin: 'anonymous',
+ subdomains: 'abc',
+};
+
+/**
+ * Tile URL template for the CARTO Dark Matter fallback basemap.
+ *
+ * ``{s}`` rotates over the ``abcd`` subdomains, ``{r}`` expands to ``@2x`` on
+ * HiDPI displays, and ``{z}/{x}/{y}`` is the standard slippy-map tile coordinate.
+ *
+ * @type {string}
+ */
+export const CARTO_TILE_URL =
'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
/**
- * Leaflet ``tileLayer`` options shared by every map.
+ * Leaflet ``tileLayer`` options describing the CARTO fallback source.
*
- * - ``maxZoom`` caps zoom at the basemap's supported detail level.
- * - ``className`` tags tiles for CSS (opacity) and offline-fallback styling.
- * - ``crossOrigin`` requests tiles with CORS so the canvas-based OG-image
- * capture and any pixel reads stay untainted (CARTO returns
- * ``access-control-allow-origin: *``).
- * - ``subdomains`` spreads requests across CARTO's four tile hosts.
- * - ``detectRetina`` serves ``@2x`` tiles on HiDPI displays.
+ * Only ``subdomains`` and ``detectRetina`` are consumed to build a fallback tile
+ * URL ({@link createBasemapLayer}); the layer itself is never instantiated,
+ * because CARTO tiles are fetched per-tile only when a HOT tile fails.
*
* @type {{maxZoom: number, className: string, crossOrigin: string, subdomains: string, detectRetina: boolean}}
*/
-export const TILE_LAYER_OPTIONS = {
+export const CARTO_TILE_OPTIONS = {
maxZoom: 19,
className: 'map-tiles',
crossOrigin: 'anonymous',
subdomains: 'abcd',
detectRetina: true,
};
+
+/**
+ * Per-tile timeout, in milliseconds, before a slow HOT tile falls back to CARTO.
+ *
+ * The single source of truth for the fallback deadline; a HOT tile that has
+ * neither loaded nor errored within this window is swapped to CARTO.
+ *
+ * @type {number}
+ */
+export const FALLBACK_TIMEOUT_MS = 1000;
+
+/**
+ * Whether the current display should request ``@2x`` (HiDPI) fallback tiles.
+ *
+ * Mirrors Leaflet's ``detectRetina`` heuristic using ``devicePixelRatio`` so the
+ * CARTO fallback matches the crispness the primary layer would deliver.
+ *
+ * @returns {boolean} ``true`` when the device pixel ratio exceeds 1.
+ */
+export function prefersRetinaTiles() {
+ return (
+ typeof globalThis !== 'undefined' &&
+ Number.isFinite(globalThis.devicePixelRatio) &&
+ globalThis.devicePixelRatio > 1
+ );
+}
+
+/**
+ * Build the shared basemap tile layer used by both maps.
+ *
+ * Returns the HOT-primary layer with per-tile CARTO fallback (built by
+ * ``createFallbackTileLayer``), or ``null`` when Leaflet (or its extendable
+ * ``TileLayer``) is unavailable. Both call sites (``main.js`` /
+ * ``federation-page.js``) only invoke this inside an existing Leaflet-presence
+ * guard, so the ``null`` return is a defensive contract exercised by unit tests
+ * rather than a runtime path with real Leaflet.
+ *
+ * @param {Object|null} L Leaflet global.
+ * @returns {Object|null} A configured Leaflet tile layer, or ``null`` when Leaflet is missing.
+ */
+export function createBasemapLayer(L) {
+ return createFallbackTileLayer(L, {
+ hotUrl: HOT_TILE_URL,
+ hotOptions: HOT_TILE_OPTIONS,
+ fallbackUrl: CARTO_TILE_URL,
+ fallbackSubdomains: CARTO_TILE_OPTIONS.subdomains,
+ fallbackRetina: prefersRetinaTiles(),
+ timeoutMs: FALLBACK_TIMEOUT_MS,
+ });
+}
diff --git a/web/public/assets/js/app/federation-page.js b/web/public/assets/js/app/federation-page.js
index 2298f2a..2652cba 100644
--- a/web/public/assets/js/app/federation-page.js
+++ b/web/public/assets/js/app/federation-page.js
@@ -24,7 +24,7 @@ import { resolveLegendVisibility } from './map-legend-visibility.js';
import { mergeConfig } from './settings.js';
import { roleColors } from './role-helpers.js';
import { meshcoreIconHtml, meshtasticIconHtml } from './protocol-helpers.js';
-import { TILE_LAYER_URL, TILE_LAYER_OPTIONS } from './basemap-config.js';
+import { createBasemapLayer } from './basemap-config.js';
/**
* Escape HTML special characters to prevent XSS.
@@ -584,7 +584,9 @@ export async function initializeFederationPage(options = {}) {
map = leaflet.map(mapContainer, { worldCopyJump: true, attributionControl: false });
map.setView([config.mapCenter.lat, config.mapCenter.lon], initialZoom);
- leaflet.tileLayer(TILE_LAYER_URL, TILE_LAYER_OPTIONS).addTo(map);
+ // Shared HOT-primary basemap with per-tile CARTO fallback (see
+ // ``./basemap-config.js``); identical to the dashboard map.
+ createBasemapLayer(leaflet).addTo(map);
markersLayer = leaflet.layerGroup().addTo(map);
}
diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js
index 0c219d2..3de5e3c 100644
--- a/web/public/assets/js/app/main.js
+++ b/web/public/assets/js/app/main.js
@@ -192,7 +192,7 @@ import {
} from './main/protocol-icons.js';
import { buildNeighborTooltipHtml, buildTraceTooltipHtml } from './main/tooltip-html.js';
import { createOfflineTileLayer as createOfflineTileLayerImpl } from './main/offline-tile-layer.js';
-import { TILE_LAYER_URL, TILE_LAYER_OPTIONS } from './basemap-config.js';
+import { createBasemapLayer } from './basemap-config.js';
import { createTileFailurePolicy } from './main/tile-failure-policy.js';
import { getActiveFullscreenElement, legendClickHandler } from './main/fullscreen-helpers.js';
import { createEventStream } from './main/event-stream.js';
@@ -1334,9 +1334,12 @@ export function initializeApp(config) {
// --- Map setup ---
- // The basemap is CARTO Dark Matter (see ``./basemap-config.js``). Its tiles
- // are natively dark-grey, so there is no per-theme CSS colour filter — the
- // old ``grayscale``/``invert`` pipeline was removed with the provider swap.
+ // The basemap is HOT (primary, dark-filtered via the ``.map-tiles-hot`` CSS
+ // rule) with a per-tile CARTO fallback, built by the shared
+ // ``createBasemapLayer`` factory (see ``./basemap-config.js`` and
+ // ``./main/fallback-tile-layer.js``). A HOT tile that errors or is slow (>1s)
+ // is individually swapped to CARTO; only a tile that fails on both providers
+ // reaches the offline placeholder wired below.
if (hasLeaflet) {
mapCenterLatLng = L.latLng(MAP_CENTER_COORDS.lat, MAP_CENTER_COORDS.lon);
@@ -1415,7 +1418,7 @@ export function initializeApp(config) {
if (hasLeaflet && mapContainer && !isFederationView && !mapAlreadyInitialized) {
map = L.map(mapContainer, { worldCopyJump: true, attributionControl: false });
showMapStatus('Loading map tiles…');
- tiles = L.tileLayer(TILE_LAYER_URL, TILE_LAYER_OPTIONS);
+ tiles = createBasemapLayer(L);
const tileFailurePolicy = createTileFailurePolicy();
const OFFLINE_TILES_MESSAGE =
'Map tiles unavailable. Showing offline placeholder basemap.';
@@ -1428,9 +1431,10 @@ export function initializeApp(config) {
});
tiles.on('tileerror', () => {
- // A single tile error no longer kills the basemap (DM3). The offline
- // fallback only fires once the policy judges the basemap comprehensively
- // unreachable (no successes after enough failures).
+ // Leaflet emits ``tileerror`` only when a tile failed on BOTH HOT and its
+ // CARTO fallback (see ``main/fallback-tile-layer.js``). A single such error
+ // still does not kill the basemap (DM3); the offline placeholder fires only
+ // once the policy judges the basemap comprehensively unreachable.
if (tileFailurePolicy.recordTileError()) {
activateOfflineTiles(OFFLINE_TILES_MESSAGE);
}
diff --git a/web/public/assets/js/app/main/__tests__/fallback-tile-layer.test.js b/web/public/assets/js/app/main/__tests__/fallback-tile-layer.test.js
new file mode 100644
index 0000000..f0ba3e8
--- /dev/null
+++ b/web/public/assets/js/app/main/__tests__/fallback-tile-layer.test.js
@@ -0,0 +1,385 @@
+/*
+ * Copyright © 2025-26 l5yth & contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ HOT_TILE_CLASS,
+ FALLBACK_TILE_CLASS,
+ buildFallbackTileUrl,
+ wireTileFallback,
+ createFallbackTileLayer,
+} from '../fallback-tile-layer.js';
+import {
+ makeFakeTile,
+ makeLeafletTileLayerStub,
+ withImgDocument,
+} from './tile-test-helpers.js';
+
+const CARTO_TEMPLATE = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
+
+/**
+ * Build a recording ``done`` callback for tile-ready assertions.
+ *
+ * @returns {{done: Function, calls: Array<{err: *, tile: *}>}} The callback and its call log.
+ */
+function makeDone() {
+ const calls = [];
+ return {
+ done: (err, tile) => calls.push({ err, tile }),
+ calls,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// buildFallbackTileUrl
+// ---------------------------------------------------------------------------
+
+test('buildFallbackTileUrl substitutes subdomain and coords without a retina suffix', () => {
+ const url = buildFallbackTileUrl(
+ { x: 3, y: 2, z: 5 },
+ { template: CARTO_TEMPLATE, subdomains: 'abcd', retina: false }
+ );
+ // (3 + 2) % 4 = 1 -> 'b'
+ assert.equal(url, 'https://b.basemaps.cartocdn.com/dark_all/5/3/2.png');
+});
+
+test('buildFallbackTileUrl appends @2x when retina is requested', () => {
+ const url = buildFallbackTileUrl(
+ { x: 1, y: 0, z: 4 },
+ { template: CARTO_TEMPLATE, subdomains: 'abcd', retina: true }
+ );
+ // (1 + 0) % 4 = 1 -> 'b'
+ assert.equal(url, 'https://b.basemaps.cartocdn.com/dark_all/4/1/0@2x.png');
+});
+
+test('buildFallbackTileUrl defaults the subdomains when none are configured', () => {
+ const url = buildFallbackTileUrl(
+ { x: 0, y: 0, z: 2 },
+ { template: CARTO_TEMPLATE, retina: false }
+ );
+ // subdomains default 'abc'; (0 + 0) % 3 = 0 -> 'a'
+ assert.equal(url, 'https://a.basemaps.cartocdn.com/dark_all/2/0/0.png');
+});
+
+test('buildFallbackTileUrl uses the absolute coord sum for the subdomain index', () => {
+ const url = buildFallbackTileUrl(
+ { x: -1, y: -2, z: 3 },
+ { template: CARTO_TEMPLATE, subdomains: 'abcd', retina: false }
+ );
+ // abs(-1 + -2) = 3 -> 3 % 4 = 3 -> 'd'
+ assert.equal(url, 'https://d.basemaps.cartocdn.com/dark_all/3/-1/-2.png');
+});
+
+// ---------------------------------------------------------------------------
+// wireTileFallback
+// ---------------------------------------------------------------------------
+
+test('wireTileFallback resolves on a HOT load and cancels the timer', () => {
+ const tile = makeFakeTile();
+ const { done, calls } = makeDone();
+ let clearedId = null;
+ const handle = wireTileFallback(tile, {
+ hotUrl: 'hot',
+ fallbackUrl: 'carto',
+ timeoutMs: 1000,
+ done,
+ setTimeoutFn: () => 7,
+ clearTimeoutFn: (id) => {
+ clearedId = id;
+ },
+ });
+ assert.equal(tile.src, 'hot');
+ tile.dispatch('load');
+ assert.equal(calls.length, 1);
+ assert.equal(calls[0].err, null);
+ assert.equal(calls[0].tile, tile);
+ assert.equal(clearedId, 7);
+ assert.equal(handle.isSettled(), true);
+ assert.equal(handle.isFallback(), false);
+});
+
+test('wireTileFallback swaps to CARTO on a HOT error and resolves on the fallback load', () => {
+ const tile = makeFakeTile();
+ const { done, calls } = makeDone();
+ const handle = wireTileFallback(tile, {
+ hotUrl: 'hot',
+ fallbackUrl: 'carto',
+ timeoutMs: 1000,
+ done,
+ setTimeoutFn: () => 1,
+ clearTimeoutFn: () => {},
+ });
+ tile.dispatch('error');
+ assert.equal(tile.src, 'carto');
+ assert.equal(tile.classList.contains(FALLBACK_TILE_CLASS), true);
+ assert.equal(tile.classList.contains(HOT_TILE_CLASS), false);
+ assert.equal(handle.isFallback(), true);
+ assert.equal(calls.length, 0);
+ // The old HOT load handler was removed; only the fallback resolves the tile.
+ assert.equal(tile.listenerCount('load'), 1);
+ tile.dispatch('load');
+ assert.equal(calls.length, 1);
+ assert.equal(calls[0].err, null);
+});
+
+test('wireTileFallback reports a tile error only when CARTO also fails', () => {
+ const tile = makeFakeTile();
+ const { done, calls } = makeDone();
+ wireTileFallback(tile, {
+ hotUrl: 'hot',
+ fallbackUrl: 'carto',
+ timeoutMs: 1000,
+ done,
+ setTimeoutFn: () => 1,
+ clearTimeoutFn: () => {},
+ });
+ tile.dispatch('error'); // HOT fails -> swap
+ assert.equal(calls.length, 0);
+ tile.dispatch('error'); // CARTO fails -> terminal error
+ assert.equal(calls.length, 1);
+ assert.ok(calls[0].err instanceof Error);
+ assert.equal(calls[0].tile, tile);
+});
+
+test('wireTileFallback passes an Error fallback event straight through', () => {
+ const tile = makeFakeTile();
+ const { done, calls } = makeDone();
+ wireTileFallback(tile, {
+ hotUrl: 'hot',
+ fallbackUrl: 'carto',
+ timeoutMs: 1000,
+ done,
+ setTimeoutFn: () => 1,
+ clearTimeoutFn: () => {},
+ });
+ tile.dispatch('error'); // swap
+ const original = new Error('carto down');
+ tile.dispatch('error', original);
+ assert.equal(calls[0].err, original);
+});
+
+test('wireTileFallback swaps to CARTO when HOT neither loads nor errors in time', () => {
+ const tile = makeFakeTile();
+ const { done, calls } = makeDone();
+ let fire = null;
+ const handle = wireTileFallback(tile, {
+ hotUrl: 'hot',
+ fallbackUrl: 'carto',
+ timeoutMs: 1000,
+ done,
+ setTimeoutFn: (fn) => {
+ fire = fn;
+ return 1;
+ },
+ clearTimeoutFn: () => {},
+ });
+ assert.equal(handle.isFallback(), false);
+ fire(); // the timeout elapses
+ assert.equal(tile.src, 'carto');
+ assert.equal(handle.isFallback(), true);
+ assert.equal(calls.length, 0);
+ tile.dispatch('load');
+ assert.equal(calls.length, 1);
+});
+
+test('wireTileFallback is inert after it settles on a HOT load', () => {
+ const tile = makeFakeTile();
+ const { done, calls } = makeDone();
+ let fire = null;
+ const handle = wireTileFallback(tile, {
+ hotUrl: 'hot',
+ fallbackUrl: 'carto',
+ timeoutMs: 1000,
+ done,
+ setTimeoutFn: (fn) => {
+ fire = fn;
+ return 1;
+ },
+ clearTimeoutFn: () => {},
+ });
+ tile.dispatch('load');
+ assert.equal(calls.length, 1);
+ tile.dispatch('load'); // second load -> handleHotLoad settled-guard
+ fire(); // late timeout -> swapToFallback settled-guard
+ assert.equal(tile.src, 'hot'); // never swapped
+ assert.equal(handle.isFallback(), false);
+ assert.equal(calls.length, 1);
+});
+
+test('wireTileFallback swaps safely when the tile has no classList', () => {
+ const tile = makeFakeTile({ withClassList: false });
+ const { done, calls } = makeDone();
+ wireTileFallback(tile, {
+ hotUrl: 'hot',
+ fallbackUrl: 'carto',
+ timeoutMs: 1000,
+ done,
+ setTimeoutFn: () => 1,
+ clearTimeoutFn: () => {},
+ });
+ tile.dispatch('error'); // swap; the classList guard is skipped
+ assert.equal(tile.src, 'carto');
+ tile.dispatch('load');
+ assert.equal(calls.length, 1);
+});
+
+test('wireTileFallback uses real timers by default and clears them on load', () => {
+ const tile = makeFakeTile();
+ const { done, calls } = makeDone();
+ wireTileFallback(tile, {
+ hotUrl: 'hot',
+ fallbackUrl: 'carto',
+ timeoutMs: 5000,
+ done,
+ });
+ tile.dispatch('load'); // cancels the real 5s timer via the default clearTimeout
+ assert.equal(calls.length, 1);
+});
+
+// ---------------------------------------------------------------------------
+// createFallbackTileLayer
+// ---------------------------------------------------------------------------
+
+test('createFallbackTileLayer returns null when Leaflet is unavailable', () => {
+ assert.equal(createFallbackTileLayer(null, {}), null);
+ assert.equal(createFallbackTileLayer({}, {}), null);
+ assert.equal(createFallbackTileLayer({ TileLayer: {} }, {}), null);
+});
+
+test('createFallbackTileLayer builds a layer from the HOT url and options', () => {
+ const L = makeLeafletTileLayerStub();
+ const layer = createFallbackTileLayer(L, {
+ hotUrl: 'https://{s}.hot/{z}/{x}/{y}.png',
+ hotOptions: { subdomains: 'abc', crossOrigin: 'anonymous' },
+ fallbackUrl: CARTO_TEMPLATE,
+ fallbackSubdomains: 'abcd',
+ fallbackRetina: false,
+ timeoutMs: 1000,
+ });
+ assert.ok(layer);
+ assert.equal(layer._url, 'https://{s}.hot/{z}/{x}/{y}.png');
+ assert.equal(layer.options.crossOrigin, 'anonymous');
+});
+
+test('createFallbackTileLayer.createTile builds a filtered HOT tile that falls back to CARTO', () => {
+ const doc = withImgDocument();
+ try {
+ const L = makeLeafletTileLayerStub();
+ const layer = createFallbackTileLayer(L, {
+ hotUrl: 'https://{s}.hot/{z}/{x}/{y}.png',
+ hotOptions: { subdomains: 'abc', crossOrigin: 'anonymous' },
+ fallbackUrl: CARTO_TEMPLATE,
+ fallbackSubdomains: 'abcd',
+ fallbackRetina: true,
+ timeoutMs: 1000,
+ });
+ const tile = layer.createTile({ x: 1, y: 1, z: 3 }, () => {});
+ assert.equal(tile.classList.contains(HOT_TILE_CLASS), true);
+ assert.equal(tile.crossOrigin, 'anonymous');
+ assert.equal(tile.alt, '');
+ assert.equal(tile._attrs.role, 'presentation');
+ // (1 + 1) % 3 = 2 -> 'c'
+ assert.equal(tile.src, 'https://c.hot/3/1/1.png');
+ tile.dispatch('error'); // force the fallback
+ // (1 + 1) % 4 = 2 -> 'c'; retina -> @2x
+ assert.equal(tile.src, 'https://c.basemaps.cartocdn.com/dark_all/3/1/1@2x.png');
+ assert.equal(tile.classList.contains(FALLBACK_TILE_CLASS), true);
+ } finally {
+ doc.restore();
+ }
+});
+
+test('createFallbackTileLayer.createTile honours the crossOrigin option variants', () => {
+ const L = makeLeafletTileLayerStub();
+ const cases = [
+ { crossOrigin: 'anonymous', expected: 'anonymous' },
+ { crossOrigin: true, expected: '' },
+ { crossOrigin: '', expected: '' },
+ { crossOrigin: undefined, expected: undefined },
+ ];
+ for (const { crossOrigin, expected } of cases) {
+ const doc = withImgDocument();
+ try {
+ const layer = createFallbackTileLayer(L, {
+ hotUrl: 'https://{s}.hot/{z}/{x}/{y}.png',
+ hotOptions: { subdomains: 'abc', crossOrigin },
+ fallbackUrl: CARTO_TEMPLATE,
+ fallbackSubdomains: 'abcd',
+ fallbackRetina: false,
+ timeoutMs: 1000,
+ });
+ const tile = layer.createTile({ x: 0, y: 0, z: 1 }, () => {});
+ assert.equal(tile.crossOrigin, expected);
+ tile.dispatch('load'); // settle to clear the scheduled timer
+ } finally {
+ doc.restore();
+ }
+ }
+});
+
+test('makeLeafletTileLayerStub applies option and subdomain defaults', () => {
+ const L = makeLeafletTileLayerStub();
+ const layer = new L.TileLayer('https://{s}.x/{z}/{x}/{y}.png'); // options omitted -> {}
+ assert.deepEqual(layer.options, {});
+ const url = layer.getTileUrl({ x: 0, y: 0, z: 1 }); // subdomains omitted -> 'abc'
+ assert.equal(url, 'https://a.x/1/0/0.png');
+});
+
+test('makeFakeTile tolerates events for unregistered types', () => {
+ const tile = makeFakeTile();
+ // listenerCount / dispatch / removeEventListener must not throw for a type
+ // that was never registered (the fake tile's defensive fallbacks).
+ assert.equal(tile.listenerCount('load'), 0);
+ tile.dispatch('load', {});
+ tile.removeEventListener('load', () => {});
+ assert.equal(tile.listenerCount('load'), 0);
+});
+
+test('withImgDocument restores a pre-existing document on teardown', () => {
+ const sentinel = { marker: true };
+ globalThis.document = sentinel;
+ try {
+ const doc = withImgDocument();
+ assert.notEqual(globalThis.document, sentinel);
+ doc.restore();
+ assert.equal(globalThis.document, sentinel);
+ } finally {
+ delete globalThis.document;
+ }
+});
+
+test('createFallbackTileLayer.createTile tolerates a tile element without classList', () => {
+ const doc = withImgDocument({ tileFactory: () => makeFakeTile({ withClassList: false }) });
+ try {
+ const L = makeLeafletTileLayerStub();
+ const layer = createFallbackTileLayer(L, {
+ hotUrl: 'https://{s}.hot/{z}/{x}/{y}.png',
+ hotOptions: { subdomains: 'abc', crossOrigin: 'anonymous' },
+ fallbackUrl: CARTO_TEMPLATE,
+ fallbackSubdomains: 'abcd',
+ fallbackRetina: false,
+ timeoutMs: 1000,
+ });
+ const tile = layer.createTile({ x: 0, y: 0, z: 1 }, () => {});
+ assert.equal(tile.classList, undefined);
+ assert.equal(tile.src, 'https://a.hot/1/0/0.png');
+ tile.dispatch('load'); // settle to clear the scheduled timer
+ } finally {
+ doc.restore();
+ }
+});
diff --git a/web/public/assets/js/app/main/__tests__/tile-test-helpers.js b/web/public/assets/js/app/main/__tests__/tile-test-helpers.js
new file mode 100644
index 0000000..9a0b63e
--- /dev/null
+++ b/web/public/assets/js/app/main/__tests__/tile-test-helpers.js
@@ -0,0 +1,166 @@
+/*
+ * Copyright © 2025-26 l5yth & contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Shared test doubles for the basemap tile layers.
+ *
+ * Deduplicates the fake ``
`` tile, the minimal Leaflet ``TileLayer`` stub,
+ * and the ``document`` stub used by both ``fallback-tile-layer.test.js`` and
+ * ``basemap-config.test.js`` (this file is not itself a test — it carries no
+ * ``.test.js`` suffix, so ``node --test`` ignores it).
+ *
+ * @module main/__tests__/tile-test-helpers
+ */
+
+/**
+ * Build a fake tile element satisfying the small DOM contract the fallback tile
+ * layer touches: ``classList``, ``addEventListener`` / ``removeEventListener``,
+ * ``setAttribute``, and the ``src`` / ``alt`` / ``crossOrigin`` properties. The
+ * extra ``dispatch`` and ``listenerCount`` helpers let a test simulate ``load`` /
+ * ``error`` events and assert listener bookkeeping.
+ *
+ * @param {{withClassList?: boolean}} [options] When ``withClassList`` is ``false``
+ * the tile omits ``classList`` (exercises the no-classList guard).
+ * @returns {Object} A fake tile element.
+ */
+export function makeFakeTile({ withClassList = true } = {}) {
+ const listeners = new Map();
+ const tile = {
+ tag: 'img',
+ src: '',
+ alt: undefined,
+ crossOrigin: undefined,
+ _attrs: {},
+ setAttribute(name, value) {
+ this._attrs[name] = value;
+ },
+ addEventListener(type, handler) {
+ if (!listeners.has(type)) listeners.set(type, []);
+ listeners.get(type).push(handler);
+ },
+ removeEventListener(type, handler) {
+ if (listeners.has(type)) {
+ listeners.set(
+ type,
+ listeners.get(type).filter((registered) => registered !== handler)
+ );
+ }
+ },
+ dispatch(type, event) {
+ (listeners.get(type) || []).slice().forEach((handler) => handler(event));
+ },
+ listenerCount(type) {
+ return (listeners.get(type) || []).length;
+ },
+ };
+ if (withClassList) {
+ const classes = new Set();
+ tile.classList = {
+ add(name) {
+ classes.add(name);
+ },
+ remove(name) {
+ classes.delete(name);
+ },
+ contains(name) {
+ return classes.has(name);
+ },
+ };
+ }
+ return tile;
+}
+
+/**
+ * Build a minimal Leaflet stub exposing ``TileLayer`` with the ``extend`` and
+ * ``getTileUrl`` behaviour ``createFallbackTileLayer`` relies on.
+ *
+ * ``getTileUrl`` mirrors Leaflet's subdomain rotation (``(x + y) % length``) and
+ * template substitution closely enough for URL assertions.
+ *
+ * @returns {{TileLayer: Function}} The Leaflet-compatible stub.
+ */
+export function makeLeafletTileLayerStub() {
+ /**
+ * @param {string} url Tile URL template.
+ * @param {Object} options Layer options.
+ */
+ function TileLayer(url, options) {
+ this._url = url;
+ this.options = options || {};
+ }
+ /**
+ * @param {{x: number, y: number, z: number}} coords Tile coordinate.
+ * @returns {string} The substituted tile URL.
+ */
+ TileLayer.prototype.getTileUrl = function getTileUrl(coords) {
+ const subdomains = this.options.subdomains || 'abc';
+ const index = Math.abs((coords.x || 0) + (coords.y || 0)) % subdomains.length;
+ return this._url
+ .replace('{s}', subdomains.charAt(index))
+ .replace('{r}', '')
+ .replace('{z}', String(coords.z))
+ .replace('{x}', String(coords.x))
+ .replace('{y}', String(coords.y));
+ };
+ /**
+ * @param {Object} proto Prototype members to mix into the subclass.
+ * @returns {Function} The subclass constructor.
+ */
+ TileLayer.extend = function extend(proto) {
+ /**
+ * @param {string} url Tile URL template.
+ * @param {Object} options Layer options.
+ */
+ function Sub(url, options) {
+ TileLayer.call(this, url, options);
+ }
+ Sub.prototype = Object.create(TileLayer.prototype);
+ Sub.prototype.constructor = Sub;
+ Object.assign(Sub.prototype, proto);
+ return Sub;
+ };
+ return { TileLayer };
+}
+
+/**
+ * Install a ``document`` stub whose ``createElement`` returns fake tiles.
+ *
+ * @param {{tileFactory?: function(): Object}} [options] Optional custom tile factory
+ * (defaults to {@link makeFakeTile}); use it to produce tiles without ``classList``.
+ * @returns {{created: Object[], restore: function(): void}} The captured tiles and a teardown handle.
+ */
+export function withImgDocument({ tileFactory } = {}) {
+ const previousDocument = globalThis.document;
+ const created = [];
+ globalThis.document = {
+ createElement(tag) {
+ const element = tileFactory ? tileFactory() : makeFakeTile();
+ element.tag = tag;
+ created.push(element);
+ return element;
+ },
+ };
+ return {
+ created,
+ restore() {
+ if (previousDocument === undefined) {
+ delete globalThis.document;
+ } else {
+ globalThis.document = previousDocument;
+ }
+ },
+ };
+}
diff --git a/web/public/assets/js/app/main/fallback-tile-layer.js b/web/public/assets/js/app/main/fallback-tile-layer.js
new file mode 100644
index 0000000..9cceaed
--- /dev/null
+++ b/web/public/assets/js/app/main/fallback-tile-layer.js
@@ -0,0 +1,259 @@
+/*
+ * Copyright © 2025-26 l5yth & contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Per-tile timeout → CARTO fallback basemap tile layer.
+ *
+ * The primary basemap is OpenStreetMap France **HOT** (Humanitarian OSM Team),
+ * dark-styled by the CSS ``grayscale``/``invert`` filter on
+ * {@link HOT_TILE_CLASS}. Any HOT tile that fires ``error`` or fails to load
+ * within a short timeout is individually swapped to the corresponding **CARTO
+ * Dark Matter** tile at the same ``z/x/y``; the swapped tile drops
+ * {@link HOT_TILE_CLASS} for {@link FALLBACK_TILE_CLASS} and renders unfiltered
+ * (CARTO is already dark, so an ``invert`` would render it light).
+ *
+ * The URL builder ({@link buildFallbackTileUrl}) and the per-tile state machine
+ * ({@link wireTileFallback}) are Leaflet-free — they operate on a plain
+ * ``
``-shaped object and injectable timers, so they unit-test standalone.
+ * {@link createFallbackTileLayer} supplies the thin Leaflet ``TileLayer``
+ * subclass that wires them to Leaflet's ``createTile`` / ``done`` contract, so an
+ * isolated HOT failure feeds Leaflet a ``tileload`` (via CARTO) rather than a
+ * ``tileerror`` — only a tile that fails on **both** providers signals
+ * ``tileerror`` to the dashboard's offline-fallback policy.
+ *
+ * @module main/fallback-tile-layer
+ */
+
+/**
+ * CSS class marking a HOT tile that receives the dark colour filter.
+ *
+ * Leaflet applies a layer's ``className`` option to the tile *container*, not to
+ * each ``
``, so per-tile filtering (HOT filtered, CARTO fallback not)
+ * requires a class on the individual tile element. This is that class.
+ *
+ * @type {string}
+ */
+export const HOT_TILE_CLASS = 'map-tiles-hot';
+
+/**
+ * CSS class marking a tile that has fallen back to CARTO (rendered unfiltered).
+ *
+ * @type {string}
+ */
+export const FALLBACK_TILE_CLASS = 'map-tiles-fallback';
+
+/**
+ * Build the CARTO fallback tile URL for a Leaflet tile coordinate.
+ *
+ * Pure string templating (no Leaflet): substitutes ``{s}`` (subdomain, chosen
+ * from ``subdomains`` by ``(x + y) % length``), ``{r}`` (``@2x`` when ``retina``,
+ * else empty), and ``{z}`` / ``{x}`` / ``{y}``. The CARTO tile at the *same*
+ * ``z/x/y`` as the HOT tile covers the identical geographic area, so no zoom
+ * offset is applied; ``@2x`` merely requests a higher-DPI render of that tile.
+ *
+ * @param {{x: number, y: number, z: number}} coords Leaflet tile coordinate.
+ * @param {{template: string, subdomains: string, retina: boolean}} config CARTO URL config.
+ * @returns {string} The fully-substituted CARTO tile URL.
+ */
+export function buildFallbackTileUrl(coords, config) {
+ const subdomains = config.subdomains || 'abc';
+ const index = Math.abs((coords.x || 0) + (coords.y || 0)) % subdomains.length;
+ const subdomain = subdomains.charAt(index);
+ const retinaSuffix = config.retina ? '@2x' : '';
+ return config.template
+ .replace('{s}', subdomain)
+ .replace('{r}', retinaSuffix)
+ .replace('{z}', String(coords.z))
+ .replace('{x}', String(coords.x))
+ .replace('{y}', String(coords.y));
+}
+
+/**
+ * Wire a tile ``
`` to the HOT-primary / CARTO-fallback state machine.
+ *
+ * Sets the HOT ``src`` and starts a ``timeoutMs`` timer. On HOT ``load`` the tile
+ * is kept (filtered) and the timer cancelled. On HOT ``error`` **or** timer
+ * expiry the tile is swapped to ``fallbackUrl``, its {@link HOT_TILE_CLASS}
+ * replaced by {@link FALLBACK_TILE_CLASS}. The Leaflet ``done(err, tile)``
+ * callback is invoked exactly once, at the terminal state: ``done(null, tile)``
+ * when HOT **or** CARTO serves the tile (Leaflet ``tileload``), and
+ * ``done(error, tile)`` only when the CARTO fallback *also* fails (``tileerror``).
+ *
+ * @param {Object} tile Tile element — a DOM ``
`` or a compatible test double
+ * exposing ``addEventListener``, ``removeEventListener``, ``classList``, and ``src``.
+ * @param {Object} options Wiring options.
+ * @param {string} options.hotUrl Primary HOT tile URL.
+ * @param {string} options.fallbackUrl CARTO fallback tile URL.
+ * @param {number} options.timeoutMs Milliseconds to wait for HOT before swapping.
+ * @param {function(Error=, Object=): void} options.done Leaflet tile-ready callback.
+ * @param {function(function, number): *} [options.setTimeoutFn] Timer scheduler (injectable for tests).
+ * @param {function(*): void} [options.clearTimeoutFn] Timer canceller (injectable for tests).
+ * @returns {{isSettled: function(): boolean, isFallback: function(): boolean}} Handle for tests/cleanup.
+ */
+export function wireTileFallback(tile, options) {
+ const {
+ hotUrl,
+ fallbackUrl,
+ timeoutMs,
+ done,
+ setTimeoutFn = setTimeout,
+ clearTimeoutFn = clearTimeout,
+ } = options;
+
+ let settled = false;
+ let fellBack = false;
+ let timer = null;
+
+ /**
+ * Cancel the pending HOT timeout. ``clearTimeout`` on a ``null`` / already-fired
+ * id is a documented no-op, so no guard is needed.
+ *
+ * @returns {void}
+ */
+ function cancelTimer() {
+ clearTimeoutFn(timer);
+ timer = null;
+ }
+
+ /**
+ * Terminal success: the HOT tile loaded before the timeout elapsed.
+ *
+ * @returns {void}
+ */
+ function handleHotLoad() {
+ if (settled) return;
+ settled = true;
+ cancelTimer();
+ done(null, tile);
+ }
+
+ /**
+ * Terminal success: the swapped-in CARTO fallback tile loaded.
+ *
+ * @returns {void}
+ */
+ function handleFallbackLoad() {
+ done(null, tile);
+ }
+
+ /**
+ * Terminal failure: the CARTO fallback tile also failed to load.
+ *
+ * @param {*} [event] The DOM error event (or an Error).
+ * @returns {void}
+ */
+ function handleFallbackError(event) {
+ done(
+ event instanceof Error ? event : new Error('basemap tile failed on both providers'),
+ tile
+ );
+ }
+
+ /**
+ * Swap the tile to the CARTO fallback source exactly once.
+ *
+ * @returns {void}
+ */
+ function swapToFallback() {
+ if (settled) return;
+ settled = true;
+ fellBack = true;
+ cancelTimer();
+ tile.removeEventListener('load', handleHotLoad);
+ tile.removeEventListener('error', handleHotError);
+ if (tile.classList) {
+ tile.classList.remove(HOT_TILE_CLASS);
+ tile.classList.add(FALLBACK_TILE_CLASS);
+ }
+ tile.addEventListener('load', handleFallbackLoad, { once: true });
+ tile.addEventListener('error', handleFallbackError, { once: true });
+ tile.src = fallbackUrl;
+ }
+
+ /**
+ * The HOT tile errored — trigger the fallback swap.
+ *
+ * @returns {void}
+ */
+ function handleHotError() {
+ swapToFallback();
+ }
+
+ tile.addEventListener('load', handleHotLoad);
+ tile.addEventListener('error', handleHotError);
+ tile.src = hotUrl;
+ timer = setTimeoutFn(swapToFallback, timeoutMs);
+
+ return {
+ isSettled: () => settled,
+ isFallback: () => fellBack,
+ };
+}
+
+/**
+ * Create the Leaflet HOT-primary / CARTO-fallback basemap tile layer.
+ *
+ * Returns ``null`` when Leaflet (or its extendable ``TileLayer``) is unavailable,
+ * so callers can degrade gracefully — mirroring ``createOfflineTileLayer``.
+ *
+ * @param {Object|null} L Leaflet global.
+ * @param {Object} config Layer configuration.
+ * @param {string} config.hotUrl Primary HOT tile-URL template.
+ * @param {Object} config.hotOptions Leaflet ``TileLayer`` options for the HOT layer.
+ * @param {string} config.fallbackUrl CARTO fallback tile-URL template.
+ * @param {string} config.fallbackSubdomains CARTO subdomains string.
+ * @param {boolean} config.fallbackRetina Whether to request ``@2x`` CARTO tiles.
+ * @param {number} config.timeoutMs Per-tile HOT timeout (ms) before falling back.
+ * @returns {Object|null} A configured Leaflet tile layer, or ``null`` when Leaflet is missing.
+ */
+export function createFallbackTileLayer(L, config) {
+ if (!L || !L.TileLayer || typeof L.TileLayer.extend !== 'function') {
+ return null;
+ }
+ const FallbackTileLayer = L.TileLayer.extend({
+ /**
+ * Create a tile ``
`` wired to the HOT→CARTO per-tile fallback.
+ *
+ * @param {{x: number, y: number, z: number}} coords Leaflet tile coordinate.
+ * @param {function(Error=, HTMLElement=): void} done Leaflet tile-ready callback.
+ * @returns {HTMLElement} The tile image element.
+ */
+ createTile(coords, done) {
+ const tile = document.createElement('img');
+ if (tile.classList) {
+ tile.classList.add(HOT_TILE_CLASS);
+ }
+ const crossOrigin = this.options.crossOrigin;
+ if (crossOrigin || crossOrigin === '') {
+ tile.crossOrigin = crossOrigin === true ? '' : crossOrigin;
+ }
+ tile.alt = '';
+ tile.setAttribute('role', 'presentation');
+ wireTileFallback(tile, {
+ hotUrl: this.getTileUrl(coords),
+ fallbackUrl: buildFallbackTileUrl(coords, {
+ template: config.fallbackUrl,
+ subdomains: config.fallbackSubdomains,
+ retina: config.fallbackRetina,
+ }),
+ timeoutMs: config.timeoutMs,
+ done,
+ });
+ return tile;
+ },
+ });
+ return new FallbackTileLayer(config.hotUrl, config.hotOptions);
+}
diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css
index 1bc4160..58e5c24 100644
--- a/web/public/assets/styles/base.css
+++ b/web/public/assets/styles/base.css
@@ -1899,6 +1899,25 @@ input[type="radio"] {
opacity: 0.75;
}
+/*
+ * Primary HOT basemap tiles are natively colourful; this static filter greys
+ * them to match the dark UI. Leaflet stamps the layer's `className` on the tile
+ * container, so the per-tile class `.map-tiles-hot` (added by
+ * `main/fallback-tile-layer.js`) is what carries the filter. A tile that has
+ * fallen back to CARTO (already dark) drops `.map-tiles-hot` for
+ * `.map-tiles-fallback` and renders unfiltered — inverting it would make it
+ * light. Offline placeholder tiles carry neither class and stay unfiltered too.
+ */
+#map .leaflet-tile.map-tiles-hot {
+ filter: grayscale(1) invert(1) brightness(0.9) contrast(1.08);
+ -webkit-filter: grayscale(1) invert(1) brightness(0.9) contrast(1.08);
+}
+
+#map .leaflet-tile.map-tiles-fallback {
+ filter: none;
+ -webkit-filter: none;
+}
+
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: #fff;