mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-07-18 23:52:27 +02:00
web: HOT primary basemap (dark-filtered) with per-tile CARTO fallback (#844)
This commit is contained in:
+132
@@ -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.
|
||||
|
||||
@@ -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 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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user