web: blend basemap providers to fix HOT/CARTO tile checkerboard (#846)

This commit is contained in:
l5y
2026-07-22 13:02:39 +02:00
committed by GitHub
parent 0cd2b2c4d4
commit 6fefce73e1
9 changed files with 289 additions and 45 deletions
+78 -4
View File
@@ -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 **BL1BL4**. 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`).
+44
View File
@@ -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 (**HT1HT3**) 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 |
@@ -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);
});
@@ -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;
+24 -12
View File
@@ -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.
+4 -3
View File
@@ -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);
@@ -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();
@@ -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}
*/
+11 -13
View File
@@ -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;