web: HOT primary basemap (dark-filtered) with per-tile CARTO fallback (#844)

This commit is contained in:
l5y
2026-07-18 11:58:05 +02:00
committed by GitHub
parent ea5560f9aa
commit ebd500981d
12 changed files with 1296 additions and 32 deletions
+132
View File
@@ -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 **HT1HT8**. 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.
+48 -2
View File
@@ -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.** `<meta name="color-scheme" content="dark light">``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 `<img>`, 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 |
@@ -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();
}
});
@@ -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 HOTCARTO 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() {
@@ -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 HOTCARTO 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);
+101 -20
View File
@@ -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,
});
}
+4 -2
View File
@@ -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);
}
+12 -8
View File
@@ -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);
}
@@ -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();
}
});
@@ -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 ``<img>`` 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;
}
},
};
}
@@ -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
* ``<img>``-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 ``<img>``, 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 ``<img>`` 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 ``<img>`` 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 ``<img>`` wired to the HOTCARTO 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);
}
+19
View File
@@ -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;