Files
meshcore-analyzer/public/map-tile-providers.js
T
Eldoon Nemar d7cd9203ca Fixes #1165: add OSM/Stamen tile providers with per-provider Leaflet layer control. (#1533)
List of changes too long to describe, so I'll hit high level.

- Config now supports the json map tiles that were suggested by
@Kpa-clawbot.
- Leaflet map layer button appears in the top right of live.js and
map.js (because all the work was already done on live.js... Added bonus)
- Allows users to enter creds for OSM and Stamen to get enterprise
related perks, in the config file
- Added a default light map under customizer. Still suggest removing
them all together and relying on the config
- You can enable OSM and Stamen in the config without a license, but at
your own risk!!!
- Config comment explains where to register and the providers for osm,
as well as the general limits per X interval
- Updated tests (28) to address the changes made to the maps

### TDD Exemption

**Reason**: Net-new UI surfaces (per `AGENTS.md`)

This PR introduces a net-new UI surface (the multi-provider map tile
selector). Under the `AGENTS.md` exemption for net-new UI surfaces, the
absence of an initial failing (red) commit is permitted, as the UI was
built first. However, the underlying public APIs are fully covered.

The following tests serve as the first assertions for these new APIs:
- `window.MC_createLayerControl`: Asserted in `MC_createLayerControl
handles Auto mode and explicit layers correctly`
- `window.MC_setDarkTileProvider` & `window.MC_getDarkTileProvider`:
Asserted in `MC_setDarkTileProvider persists to localStorage...`
- `window.MC_setLightTileProvider` & `window.MC_getLightTileProvider`:
Asserted in `MC_setLightTileProvider persists to localStorage...`
- `window.MC_initTileRegistry`: Asserted in `MC_initTileRegistry(true)
dispatches mc-tile-provider-changed`
- `applyTileFilter`: Asserted in `applyTileFilter sets invert CSS for
inverted dark provider...`
- Cross-tab synchronization: Asserted in `Cross-tab storage event
re-dispatches mc-tile-provider-changed`
2026-06-04 06:53:30 -07:00

390 lines
18 KiB
JavaScript

/* map-tile-providers.js — Registry of map tile providers & runtime switcher (#1165/#1420).
*
* Scope:
* - Multiple providers: Carto (default), OSM, Stamen, Esri.
* - MC_setDarkTileProvider(id) / MC_setLightTileProvider(id) persist per-browser
* to localStorage and dispatch `mc-tile-provider-changed`.
* - Resolves localStorage → server default → 'carto-dark' / 'carto-light'.
* - MC_applyTileFilter() applies/clears the CSS filter on
* `.leaflet-tile-pane` based on current theme + selected provider.
* - MC_createLayerControl() builds a Leaflet control mapping providers.
*
* No new deps — URL-only providers.
*/
(function () {
'use strict';
var STORAGE_KEY = 'mc-dark-tile-provider';
var STORAGE_KEY_LIGHT = 'mc-light-tile-provider';
var DEFAULT_ID = 'carto-dark';
var DEFAULT_ID_LIGHT = 'carto-light';
var EVENT_NAME = 'mc-tile-provider-changed';
var _serverDefault = null;
var _serverDefaultLight = null;
var INVERT_CSS = 'invert(1) hue-rotate(180deg) brightness(0.9) contrast(1.05)';
var _cfg = null;
var _getCartoBase = function() { return (_cfg && _cfg.providers && _cfg.providers.carto && _cfg.providers.carto.domain) ? 'https://{s}.' + _cfg.providers.carto.domain + '.cartocdn.com' : 'https://{s}.basemaps.cartocdn.com'; };
var _getStamenUrl = function() { return 'https://tiles.stadiamaps.com/tiles/stamen_toner_lite/{z}/{x}/{y}{r}.png' + ((_cfg && _cfg.providers && _cfg.providers.stamen && _cfg.providers.stamen.token) ? '?api_key=' + encodeURIComponent(_cfg.providers.stamen.token) : ''); };
var _getOsmUrl = function() {
if (_cfg && _cfg.providers && _cfg.providers.osm && _cfg.providers.osm.provider && _cfg.providers.osm.token) {
var prov = _cfg.providers.osm.provider.toLowerCase();
var key = encodeURIComponent(_cfg.providers.osm.token);
if (prov === 'thunderforest') return 'https://{s}.tile.thunderforest.com/osm-carto/{z}/{x}/{y}.png?apikey=' + key;
if (prov === 'maptiler') return 'https://api.maptiler.com/maps/streets/{z}/{x}/{y}.png?key=' + key;
if (prov === 'mapbox') return 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/256/{z}/{x}/{y}@2x?access_token=' + key;
}
return 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
};
var BASE_STYLES = {
'carto-dark': { provider: 'carto', label: 'Carto Dark', url: function() { return _getCartoBase() + '/dark_all/{z}/{x}/{y}{r}.png'; }, invertFilter: null, type: 'dark', attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 },
'carto-light': { provider: 'carto', label: 'Carto Positron', url: function() { return _getCartoBase() + '/light_all/{z}/{x}/{y}{r}.png'; }, invertFilter: null, type: 'light', attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 },
'carto-voyager': { provider: 'carto', label: 'Carto Voyager', url: function() { return _getCartoBase() + '/rastertiles/voyager/{z}/{x}/{y}{r}.png'; }, invertFilter: null, type: 'light', attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 },
'carto-voyager-dark': { provider: 'carto', label: 'Carto Voyager', url: function() { return _getCartoBase() + '/rastertiles/voyager/{z}/{x}/{y}{r}.png'; }, invertFilter: INVERT_CSS, type: 'dark', attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 },
'positron-dark': { provider: 'carto', label: 'Carto Positron', url: function() { return _getCartoBase() + '/light_all/{z}/{x}/{y}{r}.png'; }, invertFilter: INVERT_CSS, type: 'dark', attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 },
'osm-standard': { provider: 'osm', label: 'OSM Standard', url: _getOsmUrl, invertFilter: null, type: 'light', attribution: '© OpenStreetMap contributors, Maps © Mapbox/Thunderforest/MapTiler', maxZoom: 18 },
'osm-dark': { provider: 'osm', label: 'OSM Standard', url: _getOsmUrl, invertFilter: INVERT_CSS, type: 'dark', attribution: '© OpenStreetMap contributors, Maps © Mapbox/Thunderforest/MapTiler', maxZoom: 18 },
'stamen-toner-lite': { provider: 'stamen', label: 'Stamen Toner Lite', url: _getStamenUrl, invertFilter: null, type: 'light', attribution: '© Stadia Maps © Stamen Design © OpenStreetMap', maxZoom: 20 },
'stamen-toner-dark': { provider: 'stamen', label: 'Stamen Toner Lite', url: _getStamenUrl, invertFilter: INVERT_CSS, type: 'dark', attribution: '© Stadia Maps © Stamen Design © OpenStreetMap', maxZoom: 20 },
'esri-darkgray-labels': { provider: 'esri', label: 'Esri Dark Gray Canvas', url: function() { return 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}'; }, invertFilter: null, type: 'dark', attribution: 'Tiles © Esri', maxZoom: 19 }
};
var REGISTRY = {};
function _hasId(id) {
return typeof id === 'string' && Object.prototype.hasOwnProperty.call(REGISTRY, id);
}
window.MC_initTileRegistry = function(fromAsync) {
_cfg = (typeof window !== 'undefined' && window.MC_MAP_CFG && window.MC_MAP_CFG.tiles) ? window.MC_MAP_CFG.tiles : null;
var HAS_CARTO = !_cfg || !_cfg.providers || !_cfg.providers.carto || _cfg.providers.carto.enabled !== false;
var HAS_OSM = _cfg && _cfg.providers && _cfg.providers.osm && _cfg.providers.osm.enabled;
var HAS_STAMEN = _cfg && _cfg.providers && _cfg.providers.stamen && _cfg.providers.stamen.enabled && !!_cfg.providers.stamen.token;
var HAS_ESRI = true; // Kept for backwards compatibility
REGISTRY = {};
for (var key in BASE_STYLES) {
var style = BASE_STYLES[key];
if (style.provider === 'carto' && HAS_CARTO) REGISTRY[key] = style;
if (style.provider === 'osm' && HAS_OSM) REGISTRY[key] = style;
if (style.provider === 'stamen' && HAS_STAMEN) REGISTRY[key] = style;
if (style.provider === 'esri' && HAS_ESRI) REGISTRY[key] = style;
}
// Keep the public reference in sync with the newly rebuilt REGISTRY
window.MC_TILE_PROVIDERS = REGISTRY;
if (_cfg && _cfg.darkDefault && _hasId(_cfg.darkDefault)) _serverDefault = _cfg.darkDefault;
if (_cfg && _cfg.lightDefault && _hasId(_cfg.lightDefault)) _serverDefaultLight = _cfg.lightDefault;
// When called after async config loads, notify maps to re-sync tiles
if (fromAsync) {
try {
var ev = (typeof CustomEvent === 'function')
? new CustomEvent('mc-tile-provider-changed', { detail: { fromConfig: true } })
: { type: 'mc-tile-provider-changed', detail: { fromConfig: true } };
window.dispatchEvent(ev);
} catch (_) {}
}
};
// Run once immediately with whatever config is available at parse time
window.MC_initTileRegistry();
function _isDark() {
try {
var attr = document.documentElement.getAttribute('data-theme');
if (attr === 'dark') return true;
if (attr === 'light') return false;
return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
} catch (_) { return false; }
}
function getActiveId() {
try {
var stored = window.localStorage && window.localStorage.getItem(STORAGE_KEY);
if (_hasId(stored) && REGISTRY[stored].type === 'dark') return stored;
} catch (_) {}
if (_cfg && _cfg.darkDefault && _hasId(_cfg.darkDefault)) return _cfg.darkDefault;
if (_hasId(_serverDefault)) return _serverDefault;
return DEFAULT_ID;
}
function getActiveLightId() {
try {
var stored = window.localStorage && window.localStorage.getItem(STORAGE_KEY_LIGHT);
if (_hasId(stored) && REGISTRY[stored].type === 'light') return stored;
} catch (_) {}
if (_cfg && _cfg.lightDefault && _hasId(_cfg.lightDefault)) return _cfg.lightDefault;
if (_hasId(_serverDefaultLight)) return _serverDefaultLight;
return DEFAULT_ID_LIGHT;
}
function setActive(id, type) {
if (!_hasId(id)) return false;
if (REGISTRY[id].type !== type) return false;
var skey = type === 'light' ? STORAGE_KEY_LIGHT : STORAGE_KEY;
try {
if (window.localStorage) window.localStorage.setItem(skey, id);
} catch (_) { }
var detail = { id: id, provider: REGISTRY[id], type: type };
try {
var ev = (typeof CustomEvent === 'function')
? new CustomEvent(EVENT_NAME, { detail: detail })
: { type: EVENT_NAME, detail: detail };
window.dispatchEvent(ev);
} catch (_) { }
applyTileFilter();
return true;
}
function setServerDefault(id) { if (_hasId(id)) _serverDefault = id; }
function setServerDefaultLight(id) { if (_hasId(id)) _serverDefaultLight = id; }
function _isDarkEffective() {
return _isDark();
}
function applyTileFilter() {
var pane;
try { pane = document.querySelector('.leaflet-tile-pane'); } catch (_) { pane = null; }
if (!pane || !pane.style) return;
// Contract: if an explicit layer was selected via the map control, it locks
// the CSS filter state. We bypass our auto-theme logic here so we don't accidentally
// invert an explicitly chosen Light map while the app theme is Dark.
if (pane.getAttribute('data-explicit-layer') === 'true') {
return;
}
var isDark = _isDarkEffective();
var id = isDark ? getActiveId() : getActiveLightId();
var p = REGISTRY[id];
pane.style.filter = (isDark && p && p.invertFilter) ? p.invertFilter : '';
}
// ── Public surface ──────────────────────────────────────────────────────
window.MC_DARK_TILE_DEFAULT = DEFAULT_ID;
window.MC_TILE_PROVIDERS = REGISTRY; // initial ref; MC_initTileRegistry keeps this in sync
window.MC_setDarkTileProvider = function(id) { return setActive(id, 'dark'); };
window.MC_setLightTileProvider = function(id) { return setActive(id, 'light'); };
window.MC_getDarkTileProvider = getActiveId;
window.MC_getLightTileProvider = getActiveLightId;
window.MC_setServerDefaultTileProvider = setServerDefault;
window.MC_setServerDefaultLightTileProvider = setServerDefaultLight;
window.MC_applyTileFilter = applyTileFilter;
/**
* Build and attach a Leaflet layer control listing "Auto (follows theme)"
* at the top, then every enabled provider as an individual selectable base
* layer. Selecting Auto delegates to the existing theme-synced tile group;
* selecting a specific provider overrides it.
*
* @param {L.Map} map - The Leaflet map instance
* @param {L.LayerGroup} autoLayerGroup - Existing group managed by _syncDarkTiles
*/
window.MC_createLayerControl = function(map, autoLayerGroup, position) {
if (typeof L === 'undefined') return null;
// Set a default position just in case it isn't provided
var controlPosition = position || 'topleft';
var AUTO_KEY = '__auto__';
var AUTO_LABEL = ' <span title="Follows the app\'s current light/dark theme">Auto</span>';
var _control = null; // current L.control.layers instance
var _layerMap = {}; // provider-id → L.tileLayer
var _isAuto = true; // true when "Auto" is the active selection
var _overrideLightId = null;
var _overrideDarkId = null;
// Restore the auto tile group and kick _syncDarkTiles
function _activateAuto() {
_isAuto = true;
try {
var pane = map.getPane('tilePane');
if (pane) pane.removeAttribute('data-explicit-layer');
} catch (_) {}
try { if (autoLayerGroup && !map.hasLayer(autoLayerGroup)) map.addLayer(autoLayerGroup); } catch (_) {}
try {
var ev = (typeof CustomEvent === 'function')
? new CustomEvent('mc-tile-provider-changed', { detail: { auto: true } })
: { type: 'mc-tile-provider-changed', detail: { auto: true } };
window.dispatchEvent(ev);
} catch (_) {}
if (typeof applyTileFilter === 'function') applyTileFilter();
}
function _buildControl() {
// Tear down existing control + explicit tile layers
if (_control) { try { _control.remove(); } catch (_) {} _control = null; }
Object.keys(_layerMap).forEach(function (id) {
try { map.removeLayer(_layerMap[id]); } catch (_) {}
});
_layerMap = {};
// Remove stale baselayerchange listeners by cloning off event (Leaflet re-adds on new control)
if (map._mcBaselayerchangeHandler) {
try { map.off('baselayerchange', map._mcBaselayerchangeHandler); } catch (_) {}
}
var isDark = _isDarkEffective();
var activeDarkId = _overrideDarkId || getActiveId();
var activeLightId = _overrideLightId || getActiveLightId();
// Light providers first, then dark — natural grouping in the UI
var lightIds = Object.keys(REGISTRY).filter(function (id) { return REGISTRY[id].type === 'light'; });
var darkIds = Object.keys(REGISTRY).filter(function (id) { return REGISTRY[id].type === 'dark'; });
function _makeLayer(id) {
var p = REGISTRY[id];
var url = typeof p.url === 'function' ? p.url() : p.url;
var layer = L.tileLayer(url, { attribution: p.attribution || '', maxZoom: p.maxZoom || 19 });
// Every explicit layer enforces its own filter and locks the pane
layer.on('add', function () {
var pane = map.getPane('tilePane');
if (pane) {
pane.setAttribute('data-explicit-layer', 'true');
pane.style.filter = p.invertFilter || ''; // Clears it if null!
}
});
_layerMap[id] = layer;
return layer;
}
// "Auto" entry is backed by the theme-synced autoLayerGroup
var baseMaps = {};
baseMaps[AUTO_KEY] = autoLayerGroup;
lightIds.forEach(function (id) { baseMaps[id] = _makeLayer(id); });
darkIds.forEach(function (id) { baseMaps[id] = _makeLayer(id); });
// Use the dynamic controlPosition variable instead of a hardcoded string
_control = L.control.layers(baseMaps, null, { position: controlPosition }).addTo(map);
// DOM surgery: map IDs to human-readable labels, inject separator, set data attributes.
// We pass raw IDs to Leaflet as keys so e.name is the exact ID without label parsing.
try {
var firstDarkId = darkIds.length > 0 ? darkIds[0] : null;
var labels = _control.getContainer().querySelectorAll('.leaflet-control-layers-base label');
for (var i = 0; i < labels.length; i++) {
var labelEl = labels[i];
var spans = labelEl.querySelectorAll('span');
if (spans.length === 0) continue;
// Target the last span to avoid nuking Leaflet's radio <input>
var span = spans[spans.length - 1];
var id = span.textContent.trim();
labelEl.setAttribute('data-tile-id', id);
if (id === AUTO_KEY) {
span.innerHTML = AUTO_LABEL;
} else if (REGISTRY[id]) {
span.innerHTML = ' ' + (REGISTRY[id].label || id);
}
if (id === firstDarkId && lightIds.length > 0) {
var sep = document.createElement('div');
sep.className = 'leaflet-control-layers-separator';
labelEl.parentNode.insertBefore(sep, labelEl);
}
}
} catch (_) {}
// Decide initial active layer
if (_isAuto) {
// Ensure autoLayerGroup is on the map; explicit layers are off
_activateAuto();
} else {
// Re-select whichever explicit provider was active before rebuild
var prevId = isDark ? activeDarkId : activeLightId;
var prevLayer = _layerMap[prevId];
if (prevLayer) {
map.addLayer(prevLayer);
try { if (autoLayerGroup && map.hasLayer(autoLayerGroup)) map.removeLayer(autoLayerGroup); } catch (_) {}
} else {
_activateAuto(); // fallback
}
}
map._mcBaselayerchangeHandler = function (e) {
if (e.name === AUTO_KEY) {
_activateAuto();
return;
}
// Explicit provider selected — the name parameter is exactly our registry ID
var selectedId = e.name;
if (!REGISTRY[selectedId]) return;
_isAuto = false;
try {
var pane = map.getPane('tilePane');
// Contract: mark the pane as explicit so applyTileFilter skips auto-theme CSS filters
if (pane) pane.setAttribute('data-explicit-layer', 'true');
} catch (_) {}
// Hide autoLayerGroup while an explicit layer is active
try { if (autoLayerGroup && map.hasLayer(autoLayerGroup)) map.removeLayer(autoLayerGroup); } catch (_) {}
var p = REGISTRY[selectedId];
if (p.type === 'light') _overrideLightId = selectedId;
else _overrideDarkId = selectedId;
// CSS invert filter is handled by the layer's own add/remove events above
};
// Now attach the correctly assigned handler
map.on('baselayerchange', map._mcBaselayerchangeHandler);
return _control;
}
_buildControl();
// Re-build when server config arrives async so newly-enabled providers appear
window.addEventListener('mc-tile-provider-changed', function (e) {
if (e && e.detail && e.detail.fromConfig) _buildControl();
});
// When the site theme flips, update the Auto tile via the existing _syncDarkTiles
// path (map.js/live.js listen for mc-tile-provider-changed and call _syncDarkTiles).
// Nothing extra needed here — autoLayerGroup is managed externally.
return _control;
};
// ── Cross-tab sync ──────────────────────────────────────────────────────
// If another tab in the same browser changes the provider, mirror the
// dispatch + filter-apply here so live map.js / live.js swap tiles too.
try {
window.addEventListener('storage', function (e) {
if (!e || (e.key !== STORAGE_KEY && e.key !== STORAGE_KEY_LIGHT)) return;
if (!_hasId(e.newValue)) return;
var detail = { id: e.newValue, provider: REGISTRY[e.newValue], crossTab: true };
try {
var ev = (typeof CustomEvent === 'function')
? new CustomEvent(EVENT_NAME, { detail: detail })
: { type: EVENT_NAME, detail: detail };
window.dispatchEvent(ev);
} catch (_) { /* dispatch optional */ }
applyTileFilter();
});
} catch (_) { /* addEventListener may not exist in some envs */ }
})();