mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-07-21 09:02:54 +02:00
260 lines
9.3 KiB
JavaScript
260 lines
9.3 KiB
JavaScript
/*
|
|
* 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);
|
|
}
|