From 6fefce73e12ba7cf567edafeda070ec15502f94c Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:02:39 +0200 Subject: [PATCH] web: blend basemap providers to fix HOT/CARTO tile checkerboard (#846) --- ACCEPTANCE.md | 82 +++++++++++++- SPEC.md | 44 +++++++ .../js/app/__tests__/basemap-blend.test.js | 107 ++++++++++++++++++ .../js/app/__tests__/basemap-config.test.js | 9 +- web/public/assets/js/app/basemap-config.js | 36 ++++-- web/public/assets/js/app/main.js | 7 +- .../__tests__/fallback-tile-layer.test.js | 12 +- .../assets/js/app/main/fallback-tile-layer.js | 13 ++- web/public/assets/styles/base.css | 24 ++-- 9 files changed, 289 insertions(+), 45 deletions(-) create mode 100644 web/public/assets/js/app/__tests__/basemap-blend.test.js diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index c754c1c..a4a2519 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -2678,6 +2678,14 @@ wiring in `web/public/assets/js/app/main.js` and federation wiring in checks from `web/`, shell checks from the repo root. ### HT-A1 — HOT is the primary basemap on both maps; CARTO retained as fallback — HT1 + +> **⚠️ CARTO-URL half superseded by BL-A2** (§ *Bugfix: Basemap provider blend +> (chess-pattern fix)*). The CARTO fallback source is intentionally migrated from +> the natively-dark Dark Matter (`dark_all`) to the *colored* Voyager +> (`rastertiles/voyager`), so the `dark_all` grep below no longer matches by +> design. The **HOT-primary half stands** (HOT is still the primary on both maps); +> **BL-A2 is the authoritative check** for the fallback source. + ```bash git grep -nE "tile\.openstreetmap\.fr/hot" -- web/public/assets/js git grep -nE "basemaps\.cartocdn\.com/dark_all" -- web/public/assets/js @@ -2693,6 +2701,17 @@ the per-tile fallback source, not the primary. HOT options (`subdomains:'abc'`, **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 + +> **⚠️ Filter-scope half superseded by BL-A1** (§ *Bugfix: Basemap provider blend +> (chess-pattern fix)*). The dark filter is intentionally **no longer HOT-only**: +> `.map-tiles-fallback` now carries the *same* filter as `.map-tiles-hot` (BL3), so +> the two providers blend. The greps below still pass unchanged (the filter is +> still one static `base.css` rule, the removed Ruby/contract machinery stays +> removed, `resolve_initial_theme` is still `"dark"`); only the *scope* prose +> ("HOT-only", "`.map-tiles-fallback { filter: none }`") is amended. **BL-A1 is the +> authoritative check** for the shared filter. Offline placeholder tiles still stay +> unfiltered. + ```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 @@ -2706,10 +2725,11 @@ 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. +(shared by `.map-tiles-hot` and `.map-tiles-fallback` per BL3; offline placeholder +tiles carry neither class and stay unfiltered); 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 @@ -2783,3 +2803,57 @@ 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. + +--- + +## Bugfix: Basemap provider blend (chess-pattern fix) + +Maps to SPEC decisions **BL1–BL4**. The graceful timeout and colored CARTO source +live in `web/public/assets/js/app/basemap-config.js`; the shared dark filter in +`web/public/assets/styles/base.css`; both are locked by +`web/public/assets/js/app/__tests__/basemap-blend.test.js`. The per-tile HOT vs +CARTO looks (dark-filtered HOT tiles beside unfiltered CARTO tiles, on a routine +1000 ms fallback) rendered the basemap as a **light/dark checkerboard**; the fix +makes fallback rare (2500 ms) **and** blends the two providers to one dark look +(colored Voyager source + shared filter). Run JS checks from `web/`, shell checks +from the repo root. + +### BL-A1 — Graceful 2500 ms timeout + colored Voyager fallback + shared filter +```bash +( cd web && node --test public/assets/js/app/__tests__/basemap-blend.test.js ) +``` +**Expected:** pass. Asserts (1) `FALLBACK_TIMEOUT_MS === 2500` (raised from the +aggressive 1000 ms, so a slow-but-arriving HOT tile beats the deadline and +fallback stays rare); (2) `CARTO_TILE_URL` targets the *colored* CARTO **Voyager** +style (`/rastertiles/voyager/`), not the natively-dark `dark_all`; and (3) +`base.css` applies the **same** `grayscale(1) invert(1) …` dark filter to +`.map-tiles-fallback` as to `.map-tiles-hot` (no longer `filter:none`). Together +these make a viewport mixing HOT and CARTO tiles render as one coherent dark +basemap instead of a checkerboard. + +### BL-A2 — No Dark Matter reference remains; Voyager is the sole fallback source +```bash +git grep -n "dark_all" -- web/public +git grep -nE "rastertiles/voyager" -- web/public/assets/js/app/basemap-config.js +``` +**Expected:** the first prints **nothing** — the natively-dark Dark Matter source +is fully replaced (production constant and test fixtures alike); the second prints +the Voyager fallback URL from the one shared basemap module. **Supersedes the +`dark_all` half of HT-A1**; the HOT-primary half of HT-A1 is unchanged (HOT is +still the primary basemap on both maps). + +### BL-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: **HT-A1** (the CARTO fallback URL is now Voyager, not `dark_all` — the +`basemap-config.test.js` / `fallback-tile-layer.test.js` fixtures are **updated**, +not removed); **HT-A2** (the dark filter now also covers `.map-tiles-fallback` — +still one static `base.css` rule; the removed Ruby/contract `tileFilters` +machinery stays removed, offline tiles stay unfiltered); **HT-A3** (the per-tile +swap mechanism is unchanged — only the timeout constant and the swapped-in URL +differ). Still green unchanged: **HT-A4 / A5 / A6 / A7** (fallback ladder, one +shared factory on both maps, no attribution, apex/contract untouched), **A1** (no +broker — the basemap hosts are raster CDNs), **B1** (all suites), and **B4** (exact +Apache header on the new `basemap-blend.test.js`). diff --git a/SPEC.md b/SPEC.md index 204b6f0..26d130e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -598,3 +598,47 @@ URL/layer config, no new package or build step. *D8 (stable contract)* — | **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 | +--- + +## Bugfix: Basemap provider blend (chess-pattern fix) + +The HOT-primary / per-tile-CARTO-fallback basemap (**HT1–HT3**) rendered a +**light/dark checkerboard** in normal use: HOT tiles that beat the per-tile +deadline showed dark-filtered, while tiles that missed it fell back to the +**unfiltered, natively-dark CARTO Dark Matter** (HT2's deliberate `filter:none` +exemption) — two visibly different looks tiling the same viewport. The mix was +*routine*, not rare, because HOT (`openstreetmap.fr`) is slow and HT3's **1000 ms** +deadline was aggressive, so a healthy fraction of tiles fell back on every load +(and re-raced, so the pattern shifted on each pan/zoom). This is a **spec-silent** +consequence of HT2 (filtered-HOT vs unfiltered-CARTO look different *by +construction*) meeting HT3 (frequent per-tile fallback) — no acceptance criterion +was violated; the contract was silent on the visual seam. The fix attacks both +halves: make fallback *rare* (graceful timeout) **and** make the two providers +*look alike* (colored CARTO source + shared dark filter). Frontend-only +(`basemap-config.js`, `base.css`); no API/DB/ingestor change and no contract move +(HT7 posture preserved: still frontend constants, off `/version` and +`data-app-config`). + +**Conflict check.** *HT1 (CARTO source = Dark Matter)* — **amended** (BL2: CARTO +Voyager). *HT2 (CARTO fallback exempt from the filter)* — **amended** (BL3: the +fallback shares HOT's filter; HT2's "already dark → don't invert" rationale is void +because the source is now light/colored). *HT3 (1000 ms)* — **amended** (BL1: +2500 ms). *HT4 / HT5 (fallback ladder, shared factory, both maps)* — +**consistent**: the per-tile swap mechanism, the offline last tier, and the single +`createBasemapLayer` factory are unchanged; the federation map shares the `#map` +filter selector, so the blend lands on both maps (parity, Invariant IV). *HT6 (no +attribution)* — **reaffirmed**: Voyager is OSM+CARTO like Dark Matter and +`attributionControl:false` is unchanged. *Apex I / privacy II / D7 / D8 / D11* — +**untouched**: Voyager is a keyless, cookieless, CORS-enabled raster CDN like Dark +Matter; no manifest/dependency/contract change, no version bump. The pattern may +still not be eliminated 100% (a genuinely-failing HOT tile whose CARTO cover is +also slow can briefly flash), but under normal latency both levers together make +it rare and, when it occurs, near-invisible. + +| # | Decision | Source | +| --- | --- | --- | +| **BL1** | **Graceful per-tile timeout: 1000 ms → 2500 ms (amends HT3).** `FALLBACK_TIMEOUT_MS` (the single source of truth in `basemap-config.js`) is raised to **2500 ms**, so a slow-but-arriving HOT tile beats the deadline instead of falling back — restoring CARTO to the *rare safety net* HT3 intended, given HOT's real-world latency. Fewer routine fallbacks is the first half of removing the checkerboard. Accepted cost: when HOT is genuinely dead, the blank→CARTO recovery for that tile is up to 2.5 s (was 1 s); the offline tier (HT4) is unaffected. | interview | +| **BL2** | **Colored CARTO source: Dark Matter → Voyager (amends HT1).** The fallback URL becomes `https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png` (same `subdomains:'abcd'`, `detectRetina`, `crossOrigin:'anonymous'`; keyless CORS CDN). Voyager is CARTO's natively-colourful, light-background raster style — chosen precisely so the *same* dark filter that greys HOT greys it too. (A natively-dark source could not be filtered to match — HT2's original constraint — which is why HT2 exempted it; BL3 removes that exemption because BL2 removes its cause.) | interview | +| **BL3** | **Shared dark filter on the fallback tile (amends HT2).** `.map-tiles-fallback` no longer renders `filter:none`; it carries the **same** `grayscale(1) invert(1) brightness(0.9) contrast(1.08)` filter as `.map-tiles-hot`, expressed as one comma-grouped `base.css` rule (the single source of truth for the value). Both providers therefore converge to one coherent dark look, so a viewport mixing HOT and CARTO tiles is no longer a checkerboard — the second half of the fix. The per-tile class swap in `fallback-tile-layer.js` is **unchanged**; only what `.map-tiles-fallback` *does* in CSS changes. Offline placeholder tiles still carry neither class and stay unfiltered. | interview | +| **BL4** | **Both maps, one rule; posture preserved (reaffirms HT5 / HT7).** The federation map shares the dashboard's `#map` container, so the single `base.css` filter rule and the shared `createBasemapLayer` factory land the blend on **both** maps identically (parity, Invariant IV). The filter value and the timeout stay **frontend constants** — no Ruby `tile_filters`, no `/version` / `data-app-config` key, no `/api/*` change, no version bump (HT7 / D8 unchanged). | proposed | + diff --git a/web/public/assets/js/app/__tests__/basemap-blend.test.js b/web/public/assets/js/app/__tests__/basemap-blend.test.js new file mode 100644 index 0000000..961a215 --- /dev/null +++ b/web/public/assets/js/app/__tests__/basemap-blend.test.js @@ -0,0 +1,107 @@ +/* + * 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. + */ + +/** + * Regression guard: basemap provider blend (chess-pattern fix). + * + * The dashboard/federation basemap renders primary HOT tiles and per-tile CARTO + * fallback tiles in the same viewport. When the two providers looked different — + * HOT dark-filtered vs a natively-dark, *unfiltered* CARTO Dark Matter tile — a + * viewport mixing both (routine, because HOT is slow and the fallback timeout was + * aggressive) rendered as a light/dark **checkerboard**. This suite locks the fix + * so the defect cannot silently return: + * + * 1. **Graceful timeout** — the per-tile HOT deadline is 2500 ms (was 1000 ms), + * so a slow-but-arriving HOT tile beats the deadline and fallback returns to + * the rare safety net it was designed to be. + * 2. **Colored fallback** — the CARTO source is the *colored* Voyager raster + * basemap (not the natively-dark Dark Matter), so the same dark filter that + * greys HOT applies meaningfully to it too. + * 3. **Shared filter (the blend)** — ``.map-tiles-fallback`` carries the *same* + * ``grayscale/invert`` dark filter as ``.map-tiles-hot`` (no longer + * ``filter: none``), so HOT and CARTO tiles converge to one coherent dark look + * instead of a checkerboard. + * + * @module __tests__/basemap-blend + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { CARTO_TILE_URL, FALLBACK_TIMEOUT_MS } from '../basemap-config.js'; + +/** Absolute path to the shared stylesheet carrying the tile filter rules. */ +const BASE_CSS_PATH = fileURLToPath(new URL('../../../styles/base.css', import.meta.url)); + +/** + * Return the declaration body of the first CSS rule whose selector mentions + * ``className``. Whitespace-tolerant so the assertions do not depend on the exact + * selector formatting (single rule vs. comma-grouped selectors both work). + * Comments are stripped first so a class name *mentioned in a comment* above an + * unrelated rule cannot be mistaken for that rule's selector. + * + * @param {string} css Full stylesheet text. + * @param {string} className Class name to locate in a selector (without the dot). + * @returns {string|null} The rule's declaration body, or ``null`` when absent. + */ +function ruleBodyFor(css, className) { + const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ''); + for (const block of withoutComments.split('}')) { + const braceIdx = block.indexOf('{'); + if (braceIdx === -1) continue; + const selector = block.slice(0, braceIdx); + if (selector.includes(className)) return block.slice(braceIdx + 1).trim(); + } + return null; +} + +/** + * Extract the standalone ``filter:`` value from a CSS declaration body, ignoring + * the ``-webkit-filter:`` vendor twin. + * + * @param {string|null} ruleBody Declaration body from {@link ruleBodyFor}. + * @returns {string|null} The filter value (e.g. ``grayscale(1) …``), or ``null``. + */ +function filterValueOf(ruleBody) { + if (!ruleBody) return null; + for (const decl of ruleBody.split(';')) { + const trimmed = decl.trim(); + if (trimmed.startsWith('filter:')) return trimmed.slice('filter:'.length).trim(); + } + return null; +} + +test('the per-tile HOT fallback timeout is graceful (2500 ms)', () => { + assert.equal(FALLBACK_TIMEOUT_MS, 2500); +}); + +test('the CARTO fallback uses a colored basemap (Voyager), not Dark Matter', () => { + assert.match(CARTO_TILE_URL, /basemaps\.cartocdn\.com\/rastertiles\/voyager/); + assert.doesNotMatch(CARTO_TILE_URL, /dark_all/); +}); + +test('CARTO fallback tiles share the same dark filter as HOT tiles (blend)', () => { + const css = readFileSync(BASE_CSS_PATH, 'utf8'); + const hotFilter = filterValueOf(ruleBodyFor(css, 'map-tiles-hot')); + const fallbackFilter = filterValueOf(ruleBodyFor(css, 'map-tiles-fallback')); + + assert.match(hotFilter, /grayscale\(1\) invert\(1\)/); + // The fallback is no longer exempt (`filter: none`); it carries HOT's filter. + assert.notEqual(fallbackFilter, 'none'); + assert.equal(fallbackFilter, hotFilter); +}); diff --git a/web/public/assets/js/app/__tests__/basemap-config.test.js b/web/public/assets/js/app/__tests__/basemap-config.test.js index 71ef9fe..8928dfb 100644 --- a/web/public/assets/js/app/__tests__/basemap-config.test.js +++ b/web/public/assets/js/app/__tests__/basemap-config.test.js @@ -40,10 +40,10 @@ test('HOT is the primary tile source; CARTO is retained for the fallback', () => 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.match(CARTO_TILE_URL, /basemaps\.cartocdn\.com\/rastertiles\/voyager/); assert.equal(CARTO_TILE_OPTIONS.subdomains, 'abcd'); assert.equal(CARTO_TILE_OPTIONS.detectRetina, true); - assert.equal(FALLBACK_TIMEOUT_MS, 1000); + assert.equal(FALLBACK_TIMEOUT_MS, 2500); }); test('prefersRetinaTiles reflects the device pixel ratio', () => { @@ -80,7 +80,10 @@ test('createBasemapLayer builds the HOT-primary layer with CARTO fallback wiring 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.src, + 'https://d.basemaps.cartocdn.com/rastertiles/voyager/6/2/1.png' + ); assert.equal(tile.classList.contains(FALLBACK_TILE_CLASS), true); } finally { globalThis.devicePixelRatio = previous; diff --git a/web/public/assets/js/app/basemap-config.js b/web/public/assets/js/app/basemap-config.js index d407515..2349f87 100644 --- a/web/public/assets/js/app/basemap-config.js +++ b/web/public/assets/js/app/basemap-config.js @@ -24,12 +24,14 @@ * * 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. + * static ``grayscale``/``invert`` CSS filter on ``.map-tiles-hot``. **CARTO + * Voyager** (also a natively colourful raster basemap) 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``) and is greyed by the + * *same* dark filter (``.map-tiles-fallback``), so a viewport mixing both + * providers renders as one coherent dark basemap rather than a light/dark + * checkerboard. Both providers are keyless, CORS-enabled public CDNs. * * @module app/basemap-config */ @@ -66,15 +68,20 @@ export const HOT_TILE_OPTIONS = { }; /** - * Tile URL template for the CARTO Dark Matter fallback basemap. + * Tile URL template for the CARTO Voyager 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. + * Voyager is CARTO's natively colourful raster style (unlike the previously used + * Dark Matter, which was already dark). A colourful source is deliberate: the + * per-tile CARTO fallback is greyed by the *same* ``grayscale``/``invert`` filter + * as HOT (``.map-tiles-fallback``), so both providers converge to the same dark + * look. ``{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'; + 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'; /** * Leaflet ``tileLayer`` options describing the CARTO fallback source. @@ -97,11 +104,16 @@ export const CARTO_TILE_OPTIONS = { * 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. + * neither loaded nor errored within this window is swapped to CARTO. Set at + * **2500 ms** (raised from an aggressive 1000 ms) so a slow-but-arriving HOT tile + * beats the deadline rather than falling back — keeping fallback the rare safety + * net it is meant to be, given HOT's real-world latency. Fewer routine fallbacks + * (combined with the shared dark filter on ``.map-tiles-fallback``) is what keeps + * a viewport from rendering as a HOT/CARTO checkerboard. * * @type {number} */ -export const FALLBACK_TIMEOUT_MS = 1000; +export const FALLBACK_TIMEOUT_MS = 2500; /** * Whether the current display should request ``@2x`` (HiDPI) fallback tiles. diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index 3de5e3c..77c9cfc 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -1337,9 +1337,10 @@ export function initializeApp(config) { // 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. + // ``./main/fallback-tile-layer.js``). A HOT tile that errors or is slow + // (>2.5s) is individually swapped to CARTO Voyager (greyed by the same dark + // filter as HOT, so the two providers blend); 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); 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 index f0ba3e8..1bc2252 100644 --- 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 @@ -30,7 +30,7 @@ import { withImgDocument, } from './tile-test-helpers.js'; -const CARTO_TEMPLATE = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; +const CARTO_TEMPLATE = 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'; /** * Build a recording ``done`` callback for tile-ready assertions. @@ -55,7 +55,7 @@ test('buildFallbackTileUrl substitutes subdomain and coords without a retina suf { 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'); + assert.equal(url, 'https://b.basemaps.cartocdn.com/rastertiles/voyager/5/3/2.png'); }); test('buildFallbackTileUrl appends @2x when retina is requested', () => { @@ -64,7 +64,7 @@ test('buildFallbackTileUrl appends @2x when retina is requested', () => { { 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'); + assert.equal(url, 'https://b.basemaps.cartocdn.com/rastertiles/voyager/4/1/0@2x.png'); }); test('buildFallbackTileUrl defaults the subdomains when none are configured', () => { @@ -73,7 +73,7 @@ test('buildFallbackTileUrl defaults the subdomains when none are configured', () { 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'); + assert.equal(url, 'https://a.basemaps.cartocdn.com/rastertiles/voyager/2/0/0.png'); }); test('buildFallbackTileUrl uses the absolute coord sum for the subdomain index', () => { @@ -82,7 +82,7 @@ test('buildFallbackTileUrl uses the absolute coord sum for the subdomain index', { 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'); + assert.equal(url, 'https://d.basemaps.cartocdn.com/rastertiles/voyager/3/-1/-2.png'); }); // --------------------------------------------------------------------------- @@ -297,7 +297,7 @@ test('createFallbackTileLayer.createTile builds a filtered HOT tile that falls b 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.src, 'https://c.basemaps.cartocdn.com/rastertiles/voyager/3/1/1@2x.png'); assert.equal(tile.classList.contains(FALLBACK_TILE_CLASS), true); } finally { doc.restore(); diff --git a/web/public/assets/js/app/main/fallback-tile-layer.js b/web/public/assets/js/app/main/fallback-tile-layer.js index 9cceaed..d86c8d6 100644 --- a/web/public/assets/js/app/main/fallback-tile-layer.js +++ b/web/public/assets/js/app/main/fallback-tile-layer.js @@ -21,9 +21,10 @@ * 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). + * Voyager** tile at the same ``z/x/y``; the swapped tile drops + * {@link HOT_TILE_CLASS} for {@link FALLBACK_TILE_CLASS}, which carries the + * *same* dark filter (both providers are natively colourful), so a fallback tile + * blends with its HOT neighbours instead of standing out as a checkerboard cell. * * The URL builder ({@link buildFallbackTileUrl}) and the per-tile state machine * ({@link wireTileFallback}) are Leaflet-free — they operate on a plain @@ -49,7 +50,11 @@ export const HOT_TILE_CLASS = 'map-tiles-hot'; /** - * CSS class marking a tile that has fallen back to CARTO (rendered unfiltered). + * CSS class marking a tile that has fallen back to CARTO. + * + * Carries the same ``grayscale``/``invert`` dark filter as {@link HOT_TILE_CLASS} + * (CARTO Voyager is natively colourful, like HOT), so a fallback tile blends with + * its HOT neighbours rather than reading as a distinct checkerboard cell. * * @type {string} */ diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index 58e5c24..aaf1a62 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -1900,24 +1900,22 @@ input[type="radio"] { } /* - * 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. + * Both basemap providers are natively colourful, light-background raster maps + * (HOT primary, CARTO Voyager fallback). This one static filter greys and + * inverts them to match the dark UI, applied identically to primary HOT tiles + * (`.map-tiles-hot`) and to per-tile CARTO fallback tiles (`.map-tiles-fallback`) + * so a viewport mixing both providers renders as one coherent dark basemap + * instead of a light/dark checkerboard. Leaflet stamps the layer's `className` + * on the tile container, so these per-tile classes (added by + * `main/fallback-tile-layer.js`) are what carry the filter. Offline placeholder + * tiles carry neither class and stay unfiltered. */ -#map .leaflet-tile.map-tiles-hot { +#map .leaflet-tile.map-tiles-hot, +#map .leaflet-tile.map-tiles-fallback { 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;