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;