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`
This commit is contained in:
Eldoon Nemar
2026-06-04 09:53:30 -04:00
committed by GitHub
parent be36cd4adb
commit d7cd9203ca
14 changed files with 1009 additions and 225 deletions
+37 -5
View File
@@ -63,7 +63,8 @@ type Config struct {
Roles map[string]interface{} `json:"roles"`
HealthThresholds *HealthThresholds `json:"healthThresholds"`
Tiles map[string]interface{} `json:"tiles"`
Map map[string]interface{} `json:"map"`
Tiles map[string]interface{} `json:"tiles"` // deprecated
SnrThresholds map[string]interface{} `json:"snrThresholds"`
DistThresholds map[string]interface{} `json:"distThresholds"`
MaxHopDist *float64 `json:"maxHopDist"`
@@ -99,10 +100,7 @@ type Config struct {
DebugAffinity bool `json:"debugAffinity,omitempty"`
// MapDarkTileProvider selects the default dark-mode basemap provider for
// new visitors. The client may override per-browser via the customizer
// (persisted to localStorage). Allowed values: "carto-dark" (default),
// "esri-darkgray-labels", "voyager-inverted", "positron-inverted". See
// public/map-tile-providers.js for the registry. #1420.
// new visitors. Deprecated: use Map.Tiles.DarkDefault instead.
MapDarkTileProvider string `json:"mapDarkTileProvider,omitempty"`
// ObserverBlacklist is a list of observer public keys to exclude from API
@@ -363,14 +361,48 @@ func LoadConfig(baseDirs ...string) (*Config, error) {
continue
}
cfg.NormalizeTimestampConfig()
cfg.migrateDeprecatedConfig()
applyCORSEnv(cfg)
return cfg, nil
}
cfg.NormalizeTimestampConfig()
cfg.migrateDeprecatedConfig()
applyCORSEnv(cfg)
return cfg, nil // defaults
}
func (c *Config) migrateDeprecatedConfig() {
migrated := false
if c.Map == nil {
c.Map = make(map[string]interface{})
}
if c.Map["tiles"] == nil {
c.Map["tiles"] = make(map[string]interface{})
}
tilesMap, ok := c.Map["tiles"].(map[string]interface{})
if !ok {
return
}
if c.MapDarkTileProvider != "" {
if tilesMap["darkDefault"] == nil {
tilesMap["darkDefault"] = c.MapDarkTileProvider
}
migrated = true
}
if len(c.Tiles) > 0 {
for k, v := range c.Tiles {
if tilesMap[k] == nil {
tilesMap[k] = v
}
}
migrated = true
}
if migrated {
fmt.Fprintf(os.Stderr, "[deprecated] Top-level 'mapDarkTileProvider' and 'tiles' keys in config.json are deprecated and will be ignored in v3.5.0 (see #1165). Please move them into 'map': { 'tiles': { ... } }.\n")
}
}
func LoadTheme(baseDirs ...string) *ThemeFile {
if len(baseDirs) == 0 {
baseDirs = []string{"."}
+2 -1
View File
@@ -396,7 +396,7 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
writeJSON(w, ClientConfigResponse{
Roles: s.cfg.Roles,
HealthThresholds: s.cfg.GetHealthThresholds().ToClientMs(),
Tiles: s.cfg.Tiles,
Map: s.cfg.Map,
SnrThresholds: s.cfg.SnrThresholds,
DistThresholds: s.cfg.DistThresholds,
MaxHopDist: s.cfg.MaxHopDist,
@@ -409,6 +409,7 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
Timestamps: s.cfg.GetTimestampConfig(),
DebugAffinity: s.cfg.DebugAffinity,
MapDarkTileProvider: s.cfg.MapDarkTileProvider,
Tiles: s.cfg.Tiles,
})
}
+3 -4
View File
@@ -992,7 +992,8 @@ type MapConfigResponse struct {
type ClientConfigResponse struct {
Roles interface{} `json:"roles"`
HealthThresholds interface{} `json:"healthThresholds"`
Tiles interface{} `json:"tiles"`
Map interface{} `json:"map"`
Tiles interface{} `json:"tiles,omitempty"` // deprecated
SnrThresholds interface{} `json:"snrThresholds"`
DistThresholds interface{} `json:"distThresholds"`
MaxHopDist interface{} `json:"maxHopDist"`
@@ -1004,9 +1005,7 @@ type ClientConfigResponse struct {
PropagationBufferMs float64 `json:"propagationBufferMs"`
Timestamps TimestampConfig `json:"timestamps"`
DebugAffinity bool `json:"debugAffinity,omitempty"`
// #1420 — server default for dark-tile provider picker. Client uses this
// as the fallback when no localStorage override is set.
MapDarkTileProvider string `json:"mapDarkTileProvider,omitempty"`
MapDarkTileProvider string `json:"mapDarkTileProvider,omitempty"` // deprecated. TODO: remove after v3.5.0
}
// ─── IATA Coords ───────────────────────────────────────────────────────────────
+24 -2
View File
@@ -55,8 +55,30 @@
"opacity": 1,
"_comment": "#1488/#1506 — outline around each map marker (live + map pages). Defaults restored to v3.7.2 visual (solid white, 2px). 'color' accepts any CSS color (hex, rgb, rgba); use rgba() or drop 'opacity' below ~0.5 to soften the outline when hundreds of nodes feel overwhelming. 'width' is the SVG stroke width (0 hides the outline entirely). Operators can override per-browser via the in-app Theme Customizer (Colors tab → Marker Stroke)."
},
"mapDarkTileProvider": "carto-dark",
"_comment_mapDarkTileProvider": "Default dark-mode basemap provider. Allowed: 'carto-dark' (Carto dark_all — default), 'esri-darkgray-labels' (Esri Dark Gray Canvas + reference labels), 'voyager-inverted' (Carto Voyager with CSS invert filter), 'positron-inverted' (Carto Positron with CSS invert filter). Light mode is unaffected. Users can override per-browser via the in-app customizer (persisted to localStorage). #1420.",
"map": {
"tiles": {
"darkDefault": "carto-dark",
"lightDefault": "carto-light",
"providers": {
"_comment_carto": "Carto is the default free-tier provider. Optional: specify 'domain' for Carto enterprise (e.g. 'mycompany' for 'https://{s}.mycompany.cartocdn.com').",
"carto": {
"enabled": true,
"domain": ""
},
"_comment_osm": "OSM providers: 'mapbox', 'thunderforest', 'maptiler'. WARNING: Tokens are sent to the browser. Apply origin/referrer restrictions in your provider dashboard.",
"osm": {
"enabled": false,
"provider": "",
"token": ""
},
"_comment_stamen": "Stamen (hosted by Stadia). WARNING: Tokens are sent to the browser. Apply origin/referrer restrictions.",
"stamen": {
"enabled": false,
"token": ""
}
}
}
},
"home": {
"heroTitle": "CoreScope",
"heroSubtitle": "Find your nodes to start monitoring them.",
+5
View File
@@ -182,6 +182,11 @@ See `config.example.json` in the repository for all available options including:
- Region filters
- Retention policies
- Geo-filtering
- Map tile providers (OSM, Stamen, Carto, etc.)
### Map Tile Providers
Map tile providers are enabled and configured via the `config.json` file. You can provide your custom API credentials (e.g. `osm_url`, `stamen_api_key`, `mapbox_api_key`) to activate external tile services. Once configured on the server, users can select their preferred tile provider from the Customizer UI on the client, and their choice will be persisted automatically.
---
+51 -15
View File
@@ -1390,7 +1390,7 @@
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Re-show first-visit gesture discoverability hints (swipe rows, swipe tabs, edge-swipe drawer, pull-to-refresh).</p>' +
'<button type="button" class="cust-dl-btn" data-cv2-reset-hints data-reset-gesture-hints>↺ Reset gesture hints</button>' +
_renderChannelsShowEncryptedToggle() +
_renderDarkTileProviderSelector() +
_renderTileProviderSelector() +
'</div>';
}
@@ -1415,24 +1415,50 @@
// ── #1420 Dark-tile provider selector ──
// Persists per-browser via MC_setDarkTileProvider; map.js / live.js
// listen for `mc-tile-provider-changed` and swap tiles live.
function _renderDarkTileProviderSelector() {
function _renderTileProviderSelector() {
var reg = (typeof window !== 'undefined') && window.MC_TILE_PROVIDERS;
if (!reg) return '';
var active = (typeof window.MC_getDarkTileProvider === 'function') ? window.MC_getDarkTileProvider() : 'carto-dark';
var ids = ['carto-dark', 'esri-darkgray-labels', 'voyager-inverted', 'positron-inverted'];
var options = ids.filter(function (id) { return reg[id]; }).map(function (id) {
var label = reg[id].label || id;
var sel = id === active ? ' selected' : '';
return '<option value="' + escAttr(id) + '"' + sel + '>' + esc(label) + '</option>';
}).join('');
return '<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Dark Map Tiles</p>' +
'<p class="cust-hint" style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Choose the dark-mode basemap. Light mode is unaffected. Inverted variants apply a CSS filter for higher contrast.</p>' +
'<div class="cust-field"><label for="cv2-dark-tile-provider">Provider</label>' +
'<select id="cv2-dark-tile-provider" data-cv2-dark-tile-provider style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
options +
var activeDark = (typeof window.MC_getDarkTileProvider === 'function') ? window.MC_getDarkTileProvider() : 'carto-dark';
var activeLight = (typeof window.MC_getLightTileProvider === 'function') ? window.MC_getLightTileProvider() : 'carto-light';
var darkIds = Object.keys(reg).filter(function(id) { return reg[id].type === 'dark'; });
var lightIds = Object.keys(reg).filter(function(id) { return reg[id].type === 'light'; });
var hasMultipleDark = darkIds.length > 1;
var hasMultipleLight = lightIds.length > 1;
if (darkIds.length === 0 && lightIds.length === 0) return ''; // hide if no options
var html = '<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Map Tiles</p>' +
'<p class="cust-hint" style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Choose the basemap providers. Available options depend on server configuration.</p>';
if (lightIds.length > 0) {
var optionsLight = lightIds.map(function (id) {
var label = reg[id].label || id;
var sel = id === activeLight ? ' selected' : '';
return '<option value="' + escAttr(id) + '"' + sel + '>' + esc(label) + '</option>';
}).join('');
html += '<div class="cust-field"><label for="cv2-light-tile-provider">Light Mode Provider</label>' +
'<select id="cv2-light-tile-provider" data-cv2-light-tile-provider style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
optionsLight +
'</select></div>';
}
if (darkIds.length > 0) {
var optionsDark = darkIds.map(function (id) {
var label = reg[id].label || id;
var sel = id === activeDark ? ' selected' : '';
return '<option value="' + escAttr(id) + '"' + sel + '>' + esc(label) + '</option>';
}).join('');
html += '<div class="cust-field"><label for="cv2-dark-tile-provider">Dark Mode Provider</label>' +
'<select id="cv2-dark-tile-provider" data-cv2-dark-tile-provider style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
optionsDark +
'</select></div>';
}
return html;
}
function _renderHome() {
var eff = _getEffective();
var h = eff.home || {};
@@ -1999,6 +2025,16 @@
});
});
// Light-tile provider dropdown — persists + fires mc-tile-provider-changed
container.querySelectorAll('[data-cv2-light-tile-provider]').forEach(function (sel) {
sel.addEventListener('change', function () {
var id = sel.value;
if (typeof window.MC_setLightTileProvider === 'function') {
window.MC_setLightTileProvider(id);
}
});
});
// #1454 Show-encrypted-channels checkbox — persists + fires
// mc-channels-show-encrypted-changed; channels.js re-fetches live.
container.querySelectorAll('[data-cv2-channels-show-encrypted]').forEach(function (cb) {
-11
View File
@@ -461,17 +461,6 @@
white-space: nowrap;
}
/* ---- Leaflet overrides for dark theme ---- */
.live-page .leaflet-control-zoom a {
background: color-mix(in srgb, var(--surface-1) 92%, transparent) !important;
backdrop-filter: blur(12px);
color: var(--text) !important;
border-color: var(--border) !important;
}
.live-page .leaflet-control-zoom a:hover {
background: rgba(59, 130, 246, 0.2) !important;
}
/* ---- Medium breakpoint (#279) + collapse toggles (#1178, #1179) ---- */
@media (max-width: 768px) {
.live-feed { width: 280px; max-height: 200px; }
+28 -9
View File
@@ -1331,43 +1331,62 @@
// #1420 — multi-provider dark-tile picker. Light mode unchanged.
let _liveDarkRefLayer = null;
function _liveResolveTile(dark) {
if (!dark) return { url: TILE_LIGHT, attribution: '© OpenStreetMap © CartoDB', refUrl: null };
const reg = window.MC_TILE_PROVIDERS || {};
if (!dark) {
const lightId = (typeof window.MC_getLightTileProvider === 'function') ? window.MC_getLightTileProvider() : null;
const lp = lightId ? (reg[lightId] || null) : null;
if (lp && lp.url) {
return { url: (typeof lp.url === 'function' ? lp.url() : lp.url), attribution: lp.attribution || '© OpenStreetMap © CartoDB', refUrl: null };
}
return { url: TILE_LIGHT, attribution: '© OpenStreetMap © CartoDB', refUrl: null };
}
const id = (typeof window.MC_getDarkTileProvider === 'function') ? window.MC_getDarkTileProvider() : 'carto-dark';
const p = reg[id] || reg['carto-dark'] || {};
return {
url: p.url || p.baseUrl || TILE_DARK,
url: (typeof p.url === 'function' ? p.url() : p.url) || (typeof p.baseUrl === 'function' ? p.baseUrl() : p.baseUrl) || TILE_DARK,
attribution: p.attribution || '© OpenStreetMap © CartoDB',
refUrl: p.refUrl || null
};
}
const liveAutoLayerGroup = L.layerGroup().addTo(map);
function _liveSyncDarkTiles(dark) {
const r = _liveResolveTile(dark);
tileLayer.setUrl(r.url);
if (tileLayer.options) tileLayer.options.attribution = r.attribution;
if (dark && r.refUrl) {
if (!_liveDarkRefLayer) {
_liveDarkRefLayer = L.tileLayer(r.refUrl, { maxZoom: 19, attribution: r.attribution }).addTo(map);
_liveDarkRefLayer = L.tileLayer(r.refUrl, { maxZoom: 19, attribution: r.attribution }).addTo(liveAutoLayerGroup);
} else {
_liveDarkRefLayer.setUrl(r.refUrl);
if (!liveAutoLayerGroup.hasLayer(_liveDarkRefLayer)) _liveDarkRefLayer.addTo(liveAutoLayerGroup);
}
} else if (_liveDarkRefLayer) {
map.removeLayer(_liveDarkRefLayer);
liveAutoLayerGroup.removeLayer(_liveDarkRefLayer);
_liveDarkRefLayer = null;
}
if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter();
// #1420 parity with map.js — refresh visible attribution credit after provider swap.
if (map.attributionControl) {
// Make sure the map is loaded before trying to update the ui
if (map && map.attributionControl) {
try { map.attributionControl._update && map.attributionControl._update(); } catch (_) {}
}
}
const _liveInitTile = _liveResolveTile(isDark);
let tileLayer = L.tileLayer(_liveInitTile.url, { maxZoom: 19, attribution: _liveInitTile.attribution }).addTo(map);
let tileLayer = L.tileLayer(_liveInitTile.url, { maxZoom: 19, attribution: _liveInitTile.attribution }).addTo(liveAutoLayerGroup);
if (isDark && _liveInitTile.refUrl) {
_liveDarkRefLayer = L.tileLayer(_liveInitTile.refUrl, { maxZoom: 19, attribution: _liveInitTile.attribution }).addTo(map);
_liveDarkRefLayer = L.tileLayer(_liveInitTile.refUrl, { maxZoom: 19, attribution: _liveInitTile.attribution }).addTo(liveAutoLayerGroup);
}
if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter();
// Add Zoom Control
L.control.zoom({ position: 'topright' }).addTo(map);
// Add Layer Control, passing 'topright' to put it on the right
if (typeof window.MC_createLayerControl === 'function') {
window.MC_createLayerControl(map, liveAutoLayerGroup, 'topright');
}
// Swap tiles when theme changes
const _themeObs = new MutationObserver(function () {
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
@@ -1376,12 +1395,12 @@
});
_themeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
// #1420 — re-render on customizer change.
window.addEventListener('mc-tile-provider-changed', function () {
window.addEventListener('mc-tile-provider-changed', function (e) {
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (e && e.detail && e.detail.type && e.detail.type !== (dark ? 'dark' : 'light')) return;
_liveSyncDarkTiles(dark);
});
L.control.zoom({ position: 'topright' }).addTo(map);
// #1485 — animations + trails need their own pane above markerPane.
// PR #1334 moved node markers from L.circleMarker (overlayPane @ 400)
+309 -55
View File
@@ -1,63 +1,102 @@
/* map-tile-providers.js Dark-tile provider registry & runtime switcher (#1420).
/* map-tile-providers.js — Registry of map tile providers & runtime switcher (#1165/#1420).
*
* Scope:
* - 4 providers: carto-dark (default), esri-darkgray-labels (base+ref),
* voyager-inverted, positron-inverted (CSS-filter variants).
* - MC_setDarkTileProvider(id) persists per-browser to localStorage and
* dispatches `mc-tile-provider-changed` so map.js / live.js can swap.
* - MC_getDarkTileProvider() resolves localStorage server default
* 'carto-dark'.
* - 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. Light mode is unchanged.
* 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)';
// Per-browser server-injected default. roles.js writes this from
// /api/config/client (cfg.mapDarkTileProvider) before any consumer reads.
var _serverDefault = null;
var _cfg = null;
// ── Registry ────────────────────────────────────────────────────────────
var REGISTRY = {
'carto-dark': {
label: 'Carto Dark (default)',
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CartoDB',
invertFilter: null
},
'esri-darkgray-labels': {
label: 'Esri Dark Gray + Labels',
// Two-layer provider: base + reference (labels) overlay.
baseUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}',
refUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Reference/MapServer/tile/{z}/{y}/{x}',
attribution: 'Tiles © Esri — Esri, DeLorme, NAVTEQ',
invertFilter: null
},
'voyager-inverted': {
label: 'Carto Voyager (inverted)',
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CartoDB',
invertFilter: INVERT_CSS
},
'positron-inverted': {
label: 'Carto Positron (inverted)',
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CartoDB',
invertFilter: INVERT_CSS
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');
@@ -67,60 +106,275 @@
} catch (_) { return false; }
}
function getActiveId() {
try {
var stored = window.localStorage && window.localStorage.getItem(STORAGE_KEY);
if (_hasId(stored)) return stored;
} catch (_) { /* localStorage may be disabled */ }
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 setActive(id) {
if (!_hasId(id)) return false;
function getActiveLightId() {
try {
if (window.localStorage) window.localStorage.setItem(STORAGE_KEY, id);
} catch (_) { /* swallow quota / disabled */ }
var detail = { id: id, provider: REGISTRY[id] };
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 (_) { /* dispatch optional */ }
// Re-apply filter immediately so callers without a listener still see it.
} catch (_) { }
applyTileFilter();
return true;
}
function setServerDefault(id) {
if (_hasId(id)) _serverDefault = id;
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;
if (!_isDark()) { pane.style.filter = ''; return; }
var id = getActiveId();
// 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 = (p && p.invertFilter) ? p.invertFilter : '';
pane.style.filter = (isDark && p && p.invertFilter) ? p.invertFilter : '';
}
// ── Public surface ──────────────────────────────────────────────────────
window.MC_TILE_PROVIDERS = REGISTRY;
window.MC_DARK_TILE_DEFAULT = DEFAULT_ID;
window.MC_setDarkTileProvider = setActive;
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) return;
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 {
+30 -9
View File
@@ -271,16 +271,25 @@
// #1420 — multi-provider dark-tile picker. Light mode unchanged.
let _darkRefLayer = null; // Esri-only: labels overlay
function _resolveTileUrl(dark) {
if (!dark) return { url: TILE_LIGHT, attribution: '© OpenStreetMap © CartoDB', refUrl: null };
const reg = window.MC_TILE_PROVIDERS || {};
if (!dark) {
const lightId = (typeof window.MC_getLightTileProvider === 'function') ? window.MC_getLightTileProvider() : null;
const lp = lightId ? (reg[lightId] || null) : null;
if (lp && lp.url) {
return { url: (typeof lp.url === 'function' ? lp.url() : lp.url), attribution: lp.attribution || '© OpenStreetMap © CartoDB', refUrl: null };
}
return { url: TILE_LIGHT, attribution: '© OpenStreetMap © CartoDB', refUrl: null };
}
const id = (typeof window.MC_getDarkTileProvider === 'function') ? window.MC_getDarkTileProvider() : 'carto-dark';
const p = reg[id] || reg['carto-dark'] || {};
return {
url: p.url || p.baseUrl || TILE_DARK,
url: (typeof p.url === 'function' ? p.url() : p.url) || (typeof p.baseUrl === 'function' ? p.baseUrl() : p.baseUrl) || TILE_DARK,
attribution: p.attribution || '© OpenStreetMap © CartoDB',
refUrl: p.refUrl || null
};
}
const autoLayerGroup = L.layerGroup().addTo(map);
function _syncDarkTiles(dark) {
const r = _resolveTileUrl(dark);
tileLayer.setUrl(r.url);
@@ -288,16 +297,18 @@
// Esri reference (labels) overlay: add when needed, remove otherwise.
if (dark && r.refUrl) {
if (!_darkRefLayer) {
_darkRefLayer = L.tileLayer(r.refUrl, { maxZoom: 19, attribution: r.attribution }).addTo(map);
_darkRefLayer = L.tileLayer(r.refUrl, { maxZoom: 19, attribution: r.attribution }).addTo(autoLayerGroup);
} else {
_darkRefLayer.setUrl(r.refUrl);
if (!autoLayerGroup.hasLayer(_darkRefLayer)) _darkRefLayer.addTo(autoLayerGroup);
}
} else if (_darkRefLayer) {
map.removeLayer(_darkRefLayer);
autoLayerGroup.removeLayer(_darkRefLayer);
_darkRefLayer = null;
}
if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter();
if (map.attributionControl) {
// Make sure the map is loaded before trying to update the ui
if (map && map.attributionControl) {
try { map.attributionControl._update && map.attributionControl._update(); } catch (_) {}
}
}
@@ -305,11 +316,17 @@
const tileLayer = L.tileLayer(_initTile.url, {
attribution: _initTile.attribution,
maxZoom: 19,
}).addTo(map);
}).addTo(autoLayerGroup);
if (isDark && _initTile.refUrl) {
_darkRefLayer = L.tileLayer(_initTile.refUrl, { maxZoom: 19, attribution: _initTile.attribution }).addTo(map);
_darkRefLayer = L.tileLayer(_initTile.refUrl, { maxZoom: 19, attribution: _initTile.attribution }).addTo(autoLayerGroup);
}
if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter();
// Add Layer Control, passing 'topleft' to put it on the left
if (typeof window.MC_createLayerControl === 'function') {
window.MC_createLayerControl(map, autoLayerGroup, 'topleft');
}
const _mapThemeObs = new MutationObserver(function () {
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
@@ -317,9 +334,10 @@
});
_mapThemeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
// #1420 — re-render when the user picks a different dark provider in the customizer.
window.addEventListener('mc-tile-provider-changed', function () {
window.addEventListener('mc-tile-provider-changed', function (e) {
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (e && e.detail && e.detail.type && e.detail.type !== (dark ? 'dark' : 'light')) return;
_syncDarkTiles(dark);
});
@@ -1483,7 +1501,10 @@
}
function _renderMarkersInner() {
markerLayer.clearLayers();
// Don't clear what doesn't exist (map first loading will throw null error)
if (markerLayer) {
markerLayer.clearLayers();
}
if (clusterGroup) clusterGroup.clearLayers();
_currentMarkerData = [];
+11 -4
View File
@@ -452,14 +452,21 @@
if (cfg.roles.sort) window.ROLE_SORT = cfg.roles.sort;
}
if (cfg.healthThresholds) Object.assign(HEALTH_THRESHOLDS, cfg.healthThresholds);
if (cfg.map) {
window.MC_MAP_CFG = cfg.map;
} else {
// Fallback for older configs
window.MC_MAP_CFG = { tiles: { providers: {} } };
}
// Backward compat for older tile URL overrides
if (cfg.tiles) {
if (cfg.tiles.dark) window.TILE_DARK = cfg.tiles.dark;
if (cfg.tiles.light) window.TILE_LIGHT = cfg.tiles.light;
} else if (cfg.map && cfg.map.tiles) {
if (cfg.map.tiles.darkUrl) window.TILE_DARK = cfg.map.tiles.darkUrl;
if (cfg.map.tiles.lightUrl) window.TILE_LIGHT = cfg.map.tiles.lightUrl;
}
// #1420 — server default for dark-tile provider picker.
if (typeof cfg.mapDarkTileProvider === 'string' && typeof window.MC_setServerDefaultTileProvider === 'function') {
window.MC_setServerDefaultTileProvider(cfg.mapDarkTileProvider);
}
if (typeof window.MC_initTileRegistry === 'function') window.MC_initTileRegistry(true);
if (cfg.snrThresholds) Object.assign(SNR_THRESHOLDS, cfg.snrThresholds);
if (cfg.distThresholds) Object.assign(DIST_THRESHOLDS, cfg.distThresholds);
if (cfg.maxHopDist != null) window.MAX_HOP_DIST = cfg.maxHopDist;
+25
View File
@@ -2282,6 +2282,31 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
.leaflet-popup-tip { background: var(--card-bg) !important; }
.leaflet-popup-content { color: var(--text) !important; font-size: 13px !important; }
/* For Leaflet layer control */
.leaflet-control-layers,
.leaflet-control-layers-expanded {
background-color: var(--card-bg) !important;
color: var(--text) !important;
border-color: var(--border) !important;
}
.leaflet-control-layers-separator {
border-top-color: var(--border) !important;
}
[data-theme="dark"] .leaflet-control-layers-toggle {
filter: invert(1);
}
/* ---- Leaflet overrides for theme ---- */
.leaflet-control-zoom a {
background: color-mix(in srgb, var(--surface-1) 92%, transparent) !important;
backdrop-filter: blur(12px);
color: var(--text) !important;
border-color: var(--border) !important;
}
.leaflet-control-zoom a:hover {
background: rgba(59, 130, 246, 0.2) !important;
}
/* Column resize handles */
.col-resize-handle {
position: absolute; top: 0; right: -4px; width: 9px; height: 100%;
+63 -34
View File
@@ -540,8 +540,8 @@ console.log('\n=== hop-resolver.js ===');
test('resolve single unique prefix', () => {
HR.init([
{ public_key: 'abcdef1234567890', name: 'NodeA', lat: 37.3, lon: -122.0 },
{ public_key: '123456abcdef0000', name: 'NodeB', lat: 37.4, lon: -122.1 },
{ public_key: 'abcdef1234567890', name: 'NodeA', lat: 37.3, lon: -122.0, role: 'repeater' },
{ public_key: '123456abcdef0000', name: 'NodeB', lat: 37.4, lon: -122.1, role: 'repeater' },
]);
const result = HR.resolve(['ab'], null, null, null, null);
assert.strictEqual(result['ab'].name, 'NodeA');
@@ -549,8 +549,8 @@ console.log('\n=== hop-resolver.js ===');
test('resolve ambiguous prefix', () => {
HR.init([
{ public_key: 'abcdef1234567890', name: 'NodeA', lat: 37.3, lon: -122.0 },
{ public_key: 'abcd001234567890', name: 'NodeC', lat: 38.0, lon: -121.0 },
{ public_key: 'abcdef1234567890', name: 'NodeA', lat: 37.3, lon: -122.0, role: 'repeater' },
{ public_key: 'abcd001234567890', name: 'NodeC', lat: 38.0, lon: -121.0, role: 'repeater' },
]);
const result = HR.resolve(['ab'], null, null, null, null);
assert.ok(result['ab'].ambiguous);
@@ -570,8 +570,8 @@ console.log('\n=== hop-resolver.js ===');
test('geo disambiguation with origin anchor', () => {
HR.init([
{ public_key: 'abcdef1234567890', name: 'NearNode', lat: 37.31, lon: -122.01 },
{ public_key: 'abcd001234567890', name: 'FarNode', lat: 50.0, lon: 10.0 },
{ public_key: 'abcdef1234567890', name: 'NearNode', lat: 37.31, lon: -122.01, role: 'repeater' },
{ public_key: 'abcd001234567890', name: 'FarNode', lat: 50.0, lon: 10.0, role: 'repeater' },
]);
const result = HR.resolve(['ab'], 37.3, -122.0, null, null);
// Should prefer the nearer node
@@ -581,8 +581,8 @@ console.log('\n=== hop-resolver.js ===');
test('regional filtering with IATA', () => {
HR.init(
[
{ public_key: 'abcdef1234567890', name: 'SFONode', lat: 37.6, lon: -122.4 },
{ public_key: 'abcd001234567890', name: 'LHRNode', lat: 51.5, lon: -0.1 },
{ public_key: 'abcdef1234567890', name: 'SFONode', lat: 37.6, lon: -122.4, role: 'repeater' },
{ public_key: 'abcd001234567890', name: 'LHRNode', lat: 51.5, lon: -0.1, role: 'repeater' },
],
{
observers: [{ id: 'obs1', iata: 'SFO' }],
@@ -726,12 +726,12 @@ console.log('\n=== pickByAffinity neighbor-graph scoring (#874) ===');
// Two nodes sharing prefix "ab", hundreds of km apart.
// NodeSF is near San Francisco, NodeDEN is near Denver.
const nodeSF = { public_key: 'ab11111111111111', name: 'NodeSF', lat: 37.7, lon: -122.4 };
const nodeDEN = { public_key: 'ab22222222222222', name: 'NodeDEN', lat: 39.7, lon: -104.9 };
const nodeSF = { public_key: 'ab11111111111111', name: 'NodeSF', lat: 37.7, lon: -122.4, role: 'repeater' };
const nodeDEN = { public_key: 'ab22222222222222', name: 'NodeDEN', lat: 39.7, lon: -104.9, role: 'repeater' };
// A known neighbor of NodeSF (in the graph)
const nodeNeighbor = { public_key: 'cc33333333333333', name: 'SFNeighbor', lat: 37.8, lon: -122.3 };
const nodeNeighbor = { public_key: 'cc33333333333333', name: 'SFNeighbor', lat: 37.8, lon: -122.3, role: 'repeater' };
// Another known node near Denver
const nodeDenNeighbor = { public_key: 'dd44444444444444', name: 'DENNeighbor', lat: 39.8, lon: -105.0 };
const nodeDenNeighbor = { public_key: 'dd44444444444444', name: 'DENNeighbor', lat: 39.8, lon: -105.0, role: 'repeater' };
test('#874: graph edge scoring picks correct regional candidate (SF)', () => {
HR.init([nodeSF, nodeDEN, nodeNeighbor, nodeDenNeighbor]);
@@ -777,7 +777,7 @@ console.log('\n=== pickByAffinity neighbor-graph scoring (#874) ===');
test('#874: centroid uses average of prev+next positions', () => {
// Prev near SF, next near Denver → centroid is midpoint (~Nevada)
// NodeDEN is closer to Nevada midpoint than NodeSF
const nodeMid = { public_key: 'ee55555555555555', name: 'MidNode', lat: 38.5, lon: -114.0 };
const nodeMid = { public_key: 'ee55555555555555', name: 'MidNode', lat: 38.5, lon: -114.0, role: 'repeater' };
HR.init([nodeSF, nodeDEN, nodeNeighbor, nodeDenNeighbor, nodeMid]);
HR.setAffinity({ edges: [] });
// Path: SFNeighbor → [ab??] → DENNeighbor
@@ -1132,12 +1132,10 @@ console.log('\n=== live.js: pruneStaleNodes ===');
const markers = ctx.window._liveNodeMarkers();
const data = ctx.window._liveNodeData();
let lastStyle = {};
let glowStyle = {};
let elStyle = {};
markers['apiStale'] = {
_glowMarker: { setStyle: function(s) { glowStyle = s; } },
getElement: function() { return { style: elStyle }; },
_staleDimmed: false,
setStyle: function(s) { lastStyle = s; },
};
data['apiStale'] = { public_key: 'apiStale', role: 'repeater', _fromAPI: true, _liveSeen: Date.now() - 96 * 3600000 };
@@ -1146,8 +1144,7 @@ console.log('\n=== live.js: pruneStaleNodes ===');
assert.ok(markers['apiStale'], 'API node should NOT be removed');
assert.ok(data['apiStale'], 'API node data should NOT be removed');
assert.ok(markers['apiStale']._staleDimmed, 'API node should be marked as dimmed');
assert.strictEqual(lastStyle.fillOpacity, 0.25, 'marker should be dimmed to 0.25 fillOpacity');
assert.strictEqual(glowStyle.fillOpacity, 0.04, 'glow should be dimmed to 0.04 fillOpacity');
assert.strictEqual(elStyle.opacity, '0.35', 'marker should be dimmed to 0.35 opacity');
});
test('pruneStaleNodes restores API nodes when they become active again', () => {
@@ -1156,12 +1153,12 @@ console.log('\n=== live.js: pruneStaleNodes ===');
const markers = ctx.window._liveNodeMarkers();
const data = ctx.window._liveNodeData();
let lastStyle = {};
let glowStyle = {};
let elStyle = {};
markers['apiNode'] = {
_glowMarker: { setStyle: function(s) { glowStyle = s; } },
getElement: function() { return { style: elStyle }; },
_glowMarker: { setStyle: function() {} },
_staleDimmed: true,
setStyle: function(s) { lastStyle = s; },
setStyle: function() {},
};
data['apiNode'] = { public_key: 'apiNode', role: 'repeater', _fromAPI: true, _liveSeen: Date.now() };
@@ -1169,8 +1166,7 @@ console.log('\n=== live.js: pruneStaleNodes ===');
assert.ok(markers['apiNode'], 'API node should remain');
assert.strictEqual(markers['apiNode']._staleDimmed, false, 'staleDimmed should be cleared');
assert.strictEqual(lastStyle.fillOpacity, 0.85, 'opacity should be restored to 0.85');
assert.strictEqual(glowStyle.fillOpacity, 0.12, 'glow should be restored to 0.12');
assert.strictEqual(elStyle.opacity, '1', 'opacity should be restored to 1');
});
test('pruneStaleNodes still removes WS-only nodes when stale', () => {
@@ -1402,6 +1398,7 @@ console.log('\n=== nodes.js: WS handler runtime behavior ===');
querySelectorAll() { return []; },
querySelector() { return null; },
getAttribute() { return null; },
setAttribute() {},
};
}
return domElements[id];
@@ -1431,6 +1428,7 @@ console.log('\n=== nodes.js: WS handler runtime behavior ===');
ctx.Set = Set;
ctx.CLIENT_TTL = { nodeList: 90000, nodeDetail: 240000, nodeHealth: 240000 };
ctx.RegionFilter = { init() {}, onChange() { return () => {}; }, getRegionParam() { return ''; }, offChange() {} };
ctx.AreaFilter = { init() {}, onChange() { return () => {}; }, getAreaParam() { return ''; }, offChange() {} };
// Track API calls and cache invalidation
let apiCallCount = 0;
@@ -1777,6 +1775,7 @@ console.log('\n=== compare.js: comparePacketSets ===');
ctx.window.addEventListener = () => {};
ctx.window.removeEventListener = () => {};
ctx.RegionFilter = { init() {}, onChange() { return () => {}; }, offChange() {}, getRegionParam() { return ''; } };
ctx.AreaFilter = { init() {}, onChange() { return () => {}; }, offChange() {}, getAreaParam() { return ''; } };
ctx.CLIENT_TTL = { observers: 120000 };
ctx.debouncedOnWS = (fn) => fn;
ctx.onWS = () => {};
@@ -3017,6 +3016,7 @@ console.log('\n=== packets.js: savedTimeWindowMin defaults ===');
ctx.window.addEventListener = () => {};
ctx.window.removeEventListener = () => {};
ctx.RegionFilter = { init() {}, onChange() { return () => {}; }, offChange() {}, getRegionParam() { return ''; } };
ctx.AreaFilter = { init() {}, onChange() { return () => {}; }, offChange() {}, getAreaParam() { return ''; } };
ctx.CLIENT_TTL = { observers: 120000 };
ctx.debouncedOnWS = (fn) => fn;
ctx.onWS = () => {};
@@ -3481,14 +3481,9 @@ console.log('\n=== live.js: nextHop null guards ===');
'nextHop must return early when animLayer is null (post-destroy)');
});
test('nextHop setInterval guards animLayer null', () => {
assert.ok(liveSource.includes('if (!animLayer || !animLayer.hasLayer(ghost))'),
'setInterval in nextHop must guard animLayer null');
});
test('nextHop setTimeout guards animLayer null', () => {
assert.ok(liveSource.includes('if (animLayer && animLayer.hasLayer(ghost)) animLayer.removeLayer(ghost)'),
'setTimeout in nextHop must guard animLayer null');
test('renderAnimations guards animLayer null before removing ghost', () => {
assert.ok(liveSource.includes('if (animLayer && animLayer.hasLayer(g.marker))'),
'renderAnimations must guard animLayer null before removing ghost');
});
test('nextHop guards liveAnimCount element null', () => {
@@ -3697,6 +3692,7 @@ function makeNodesSandbox(opts) {
loadInCtx(ctx, 'public/app.js');
ctx.registerPage = () => {};
ctx.RegionFilter = { init: () => {}, onChange: () => () => {}, getRegionParam: () => '', offChange: () => {} };
ctx.AreaFilter = { init: () => {}, onChange: () => () => {}, getAreaParam: () => '', offChange: () => {} };
ctx.onWS = () => {};
ctx.offWS = () => {};
ctx.debouncedOnWS = (fn) => fn;
@@ -4143,7 +4139,7 @@ console.log('\n=== app.js: payloadTypeColor ===');
test('payloadTypeColor(99) = unknown', () => assert.strictEqual(payloadTypeColor(99), 'unknown'));
test('payloadTypeColor(null) = unknown', () => assert.strictEqual(payloadTypeColor(null), 'unknown'));
test('payloadTypeColor(undefined) = unknown', () => assert.strictEqual(payloadTypeColor(undefined), 'unknown'));
test('payloadTypeColor(6) = unknown (no mapping for 6)', () => assert.strictEqual(payloadTypeColor(6), 'unknown'));
test('payloadTypeColor(12) = unknown (no mapping for 12)', () => assert.strictEqual(payloadTypeColor(12), 'unknown'));
test('all defined payload types return a non-unknown string', () => {
const definedTypes = [0, 1, 2, 3, 4, 5, 7, 8, 9];
for (const t of definedTypes) {
@@ -4984,6 +4980,7 @@ console.log('\n=== region-filter.js: setSelected ===');
removeEventListener: () => {},
});
loadInCtx(ctx, 'public/area-filter.js');
loadInCtx(ctx, 'public/region-filter.js');
const RF = ctx.RegionFilter;
@@ -5083,6 +5080,7 @@ console.log('\n=== packets.js: buildPacketsQuery ===');
ctx.registerPage = () => {};
ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '', setSelected: () => {} };
ctx.AreaFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getAreaParam: () => '', setSelected: () => {} };
ctx.onWS = () => {};
ctx.offWS = () => {};
ctx.debouncedOnWS = () => () => {};
@@ -6377,6 +6375,37 @@ console.log('\n=== app.js: formatChartAxisLabel ===');
ctx.localStorage.removeItem('meshcore-timestamp-timezone');
}
// ===== roles.js: Map Tile Config Parsing =====
console.log('\n=== roles.js: Map Tile Config Parsing ===');
{
function makeRolesSandbox(cfg) {
const ctx = makeSandbox();
const rolesJs = fs.readFileSync(__dirname + '/public/roles.js', 'utf8');
ctx.fetch = () => Promise.resolve({ json: () => Promise.resolve(cfg) });
ctx.window.fetch = ctx.fetch;
// Load it
vm.runInNewContext(rolesJs, ctx);
return ctx;
}
test('mapTiles sets MC_MAP_CFG when map config is missing', async () => {
const ctx = makeRolesSandbox({});
await ctx.window.MeshConfigReady;
assert.deepStrictEqual(JSON.parse(JSON.stringify(ctx.window.MC_MAP_CFG)), { tiles: { providers: {} } }, 'Should fallback to empty providers');
});
test('mapTiles exposes map config directly to MC_MAP_CFG', async () => {
const mapCfg = {
tiles: {
providers: { carto: { enabled: true, domain: 'test-carto' }, stamen: { enabled: true, token: 'test-stamen' } }
}
};
const ctx = makeRolesSandbox({ map: mapCfg });
await ctx.window.MeshConfigReady;
assert.deepStrictEqual(ctx.window.MC_MAP_CFG, mapCfg, 'MC_MAP_CFG should equal cfg.map');
});
}
// ===== SUMMARY =====
Promise.allSettled(pendingTests).then(() => {
console.log(`\n${'═'.repeat(40)}`);
+421 -76
View File
@@ -1,28 +1,31 @@
/* test-issue-1420-tile-providers.js Dark-tile provider registry tests.
/* test-issue-1420-tile-providers.js Tile provider registry tests.
*
* Asserts the MC_TILE_PROVIDERS registry shape, persistence helpers,
* and CSS-filter swap behavior for inverted variants. Tests via VM
* sandbox (no jsdom dep). Follows the pattern from cb-presets tests.
* Covers MC_initTileRegistry config-gating, dark + light persistence
* helpers, CSS-filter swap, OSM/Stamen provider enable/disable, and
* the fromAsync dispatch that re-syncs maps after config loads.
*
* Runs via: node test-issue-1420-tile-providers.js
* No jsdom or Playwright dependency pure vm sandbox.
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const vm = require('vm');
const fs = require('fs');
const path = require('path');
const assert = require('assert');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(' ' + name); }
catch (e) { failed++; console.log(' ' + name + ': ' + e.message); }
try { fn(); passed++; console.log(' \u2705 ' + name); }
catch (e) { failed++; console.log(' \u274c ' + name + ': ' + e.message); }
}
function makeStorage() {
const store = {};
return {
getItem(k) { return Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null; },
setItem(k, v) { store[k] = String(v); },
removeItem(k) { delete store[k]; },
clear() { for (const k of Object.keys(store)) delete store[k]; },
getItem(k) { return Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null; },
setItem(k, v) { store[k] = String(v); },
removeItem(k) { delete store[k]; },
clear() { for (const k of Object.keys(store)) delete store[k]; },
_raw: store
};
}
@@ -31,7 +34,13 @@ function makeSandbox(opts) {
opts = opts || {};
const events = [];
const listeners = {};
const tilePane = { style: { filter: '' } };
const _paneAttrs = {};
const tilePane = {
style: { filter: '' },
setAttribute: (k, v) => { _paneAttrs[k] = String(v); },
getAttribute: (k) => Object.prototype.hasOwnProperty.call(_paneAttrs, k) ? _paneAttrs[k] : null,
removeAttribute: (k) => { delete _paneAttrs[k]; }
};
const ctx = {
console,
setTimeout, clearTimeout,
@@ -45,133 +54,469 @@ function makeSandbox(opts) {
},
window: {
addEventListener: (type, fn) => { (listeners[type] = listeners[type] || []).push(fn); },
dispatchEvent: (ev) => { events.push(ev); return true; },
matchMedia: () => ({ matches: false, addEventListener: () => {} }),
dispatchEvent: (ev) => { events.push(ev); return true; },
matchMedia: () => ({ matches: false, addEventListener: () => {} }),
},
CustomEvent: function (type, init) { this.type = type; this.detail = (init && init.detail) || null; }
};
ctx.window.localStorage = ctx.localStorage;
ctx.globalThis = ctx;
vm.createContext(ctx);
// Make window mirror globals (the module uses window.X assignment)
ctx.window.document = ctx.document;
ctx.events = events;
ctx.events = events;
ctx.listeners = listeners;
ctx.tilePane = tilePane;
ctx.tilePane = tilePane;
return ctx;
}
function loadProviders(ctx) {
function loadProviders(ctx, mapCfg) {
// Optionally pre-populate MC_MAP_CFG before the IIFE runs
if (mapCfg !== undefined) ctx.window.MC_MAP_CFG = mapCfg;
const src = fs.readFileSync(path.join(__dirname, 'public', 'map-tile-providers.js'), 'utf8');
vm.runInContext(src, ctx);
}
console.log('── #1420 Dark-tile provider registry ──');
// ─── Helpers ────────────────────────────────────────────────────────────────
test('MC_TILE_PROVIDERS has all 4 IDs with url + attribution', () => {
const ALL_CARTO_IDS = ['carto-dark', 'carto-light', 'carto-voyager', 'carto-voyager-dark', 'positron-dark'];
const ALL_OSM_IDS = ['osm-standard', 'osm-dark'];
const ALL_STAMEN_IDS = ['stamen-toner-lite', 'stamen-toner-dark'];
const ALL_ESRI_IDS = ['esri-darkgray-labels'];
const ALL_IDS = [...ALL_CARTO_IDS, ...ALL_OSM_IDS, ...ALL_STAMEN_IDS, ...ALL_ESRI_IDS];
console.log('\u2500\u2500 #1420 Tile provider registry \u2500\u2500');
// ─── Registry shape ──────────────────────────────────────────────────────────
test('Default registry (no MC_MAP_CFG) contains only Carto providers', () => {
const ctx = makeSandbox();
loadProviders(ctx);
const reg = ctx.window.MC_TILE_PROVIDERS;
assert.ok(reg, 'registry must exist on window');
const ids = ['carto-dark', 'esri-darkgray-labels', 'voyager-inverted', 'positron-inverted'];
for (const id of ids) {
assert.ok(reg[id], 'missing provider: ' + id);
assert.ok(typeof reg[id].attribution === 'string' && reg[id].attribution.length > 0, id + ' attribution');
// Esri uses baseUrl + refUrl; others use url
if (id === 'esri-darkgray-labels') {
assert.ok(typeof reg[id].baseUrl === 'string' && reg[id].baseUrl.indexOf('{z}') >= 0, 'esri baseUrl');
assert.ok(typeof reg[id].refUrl === 'string' && reg[id].refUrl.indexOf('{z}') >= 0, 'esri refUrl');
} else {
assert.ok(typeof reg[id].url === 'string' && reg[id].url.indexOf('{z}') >= 0, id + ' url has {z}');
}
for (const id of ALL_CARTO_IDS) assert.ok(reg[id], 'should have ' + id);
for (const id of [...ALL_OSM_IDS, ...ALL_STAMEN_IDS]) assert.ok(!reg[id], 'should NOT have ' + id + ' without config');
});
test('Every registry entry has a url function or string with {z}', () => {
const ctx = makeSandbox();
loadProviders(ctx, { tiles: { providers: { carto: { enabled: true }, osm: { enabled: true }, stamen: { enabled: true, token: 'x' } } } });
ctx.window.MC_initTileRegistry(false);
const reg = ctx.window.MC_TILE_PROVIDERS;
assert.ok(Object.keys(reg).length >= 10, 'registry must contain all 10 providers when all enabled');
for (const id of ALL_IDS) assert.ok(reg[id], 'missing provider: ' + id);
for (const id of Object.keys(reg)) {
const p = reg[id];
const url = typeof p.url === 'function' ? p.url() : p.url;
assert.ok(typeof url === 'string' && url.indexOf('{z}') >= 0, id + ' url must have {z}');
assert.ok(typeof p.attribution === 'string' && p.attribution.length > 0, id + ' needs attribution');
}
});
test('Inverted providers have non-null invertFilter; non-inverted have null', () => {
test('Every registry entry has a type of light or dark', () => {
const ctx = makeSandbox();
loadProviders(ctx, { tiles: { providers: { carto: { enabled: true }, osm: { enabled: true }, stamen: { enabled: true, token: 'x' } } } });
ctx.window.MC_initTileRegistry(false);
const reg = ctx.window.MC_TILE_PROVIDERS;
assert.ok(Object.keys(reg).length >= 10, 'registry must contain all 10 providers when all enabled');
for (const id of ALL_IDS) assert.ok(reg[id], 'missing provider: ' + id);
for (const id of Object.keys(reg)) {
assert.ok(reg[id].type === 'light' || reg[id].type === 'dark', id + ' must have type light or dark');
}
});
// ─── Provider gating ─────────────────────────────────────────────────────────
test('OSM providers appear when osm.enabled=true in MC_MAP_CFG', () => {
const ctx = makeSandbox();
loadProviders(ctx);
ctx.window.MC_MAP_CFG = { tiles: { providers: { osm: { enabled: true } } } };
ctx.window.MC_initTileRegistry(false);
const reg = ctx.window.MC_TILE_PROVIDERS;
assert.strictEqual(reg['carto-dark'].invertFilter, null);
assert.strictEqual(reg['esri-darkgray-labels'].invertFilter, null);
assert.ok(typeof reg['voyager-inverted'].invertFilter === 'string' && reg['voyager-inverted'].invertFilter.indexOf('invert(') >= 0);
assert.ok(typeof reg['positron-inverted'].invertFilter === 'string' && reg['positron-inverted'].invertFilter.indexOf('invert(') >= 0);
for (const id of ALL_OSM_IDS) assert.ok(reg[id], 'should have ' + id + ' when osm enabled');
});
test('OSM providers absent when osm.enabled=false', () => {
const ctx = makeSandbox();
loadProviders(ctx);
ctx.window.MC_MAP_CFG = { tiles: { providers: { osm: { enabled: false } } } };
ctx.window.MC_initTileRegistry(false);
const reg = ctx.window.MC_TILE_PROVIDERS;
for (const id of ALL_OSM_IDS) assert.ok(!reg[id], id + ' should be absent when disabled');
});
test('Stamen providers appear when stamen.enabled=true and token provided', () => {
const ctx = makeSandbox();
loadProviders(ctx);
ctx.window.MC_MAP_CFG = { tiles: { providers: { stamen: { enabled: true, token: 'x' } } } };
ctx.window.MC_initTileRegistry(false);
const reg = ctx.window.MC_TILE_PROVIDERS;
for (const id of ALL_STAMEN_IDS) assert.ok(reg[id], 'should have ' + id + ' when stamen enabled');
});
test('Carto absent only when carto.enabled=false', () => {
const ctx = makeSandbox();
loadProviders(ctx);
ctx.window.MC_MAP_CFG = { tiles: { providers: { carto: { enabled: false } } } };
ctx.window.MC_initTileRegistry(false);
const reg = ctx.window.MC_TILE_PROVIDERS;
for (const id of ALL_CARTO_IDS) assert.ok(!reg[id], id + ' should be absent when carto disabled');
});
test('Carto present when carto config is missing entirely (default on)', () => {
const ctx = makeSandbox();
loadProviders(ctx);
ctx.window.MC_MAP_CFG = { tiles: { providers: {} } };
ctx.window.MC_initTileRegistry(false);
const reg = ctx.window.MC_TILE_PROVIDERS;
for (const id of ALL_CARTO_IDS) assert.ok(reg[id], id + ' should exist when carto has no enabled flag');
});
// ─── invertFilter ────────────────────────────────────────────────────────────
test('Dark-inverted providers have non-null invertFilter; others have null', () => {
const ctx = makeSandbox();
loadProviders(ctx, { tiles: { providers: { osm: { enabled: true }, stamen: { enabled: true, token: 'x' } } } });
ctx.window.MC_initTileRegistry(false);
const reg = ctx.window.MC_TILE_PROVIDERS;
// Explicit dark (invert) entries
for (const id of ['carto-voyager-dark', 'positron-dark', 'osm-dark', 'stamen-toner-dark']) {
assert.ok(typeof reg[id].invertFilter === 'string' && reg[id].invertFilter.indexOf('invert(') >= 0,
id + ' must have invert CSS filter');
}
// Explicit light (no invert) entries
for (const id of ['carto-light', 'carto-voyager', 'osm-standard', 'stamen-toner-lite']) {
assert.strictEqual(reg[id].invertFilter, null, id + ' must NOT have an invert filter');
}
// Carto-dark has no invert filter (it's a native dark tile)
assert.strictEqual(reg['carto-dark'].invertFilter, null, 'carto-dark is a native dark tile — no invert filter');
});
// ─── Dark provider persistence ────────────────────────────────────────────────
test('MC_setDarkTileProvider persists to localStorage and dispatches mc-tile-provider-changed', () => {
const ctx = makeSandbox();
loadProviders(ctx);
ctx.window.MC_setDarkTileProvider('voyager-inverted');
assert.strictEqual(ctx.localStorage.getItem('mc-dark-tile-provider'), 'voyager-inverted');
ctx.window.MC_setDarkTileProvider('carto-voyager-dark');
assert.strictEqual(ctx.localStorage.getItem('mc-dark-tile-provider'), 'carto-voyager-dark');
assert.ok(ctx.events.length >= 1, 'event dispatched');
const ev = ctx.events[ctx.events.length - 1];
assert.strictEqual(ev.type, 'mc-tile-provider-changed');
assert.ok(ev.detail && ev.detail.id === 'voyager-inverted');
assert.ok(ev.detail && ev.detail.id === 'carto-voyager-dark');
});
test('MC_setDarkTileProvider rejects unknown IDs (no persistence, no dispatch)', () => {
test('MC_setDarkTileProvider rejects unknown IDs', () => {
const ctx = makeSandbox();
loadProviders(ctx);
const ok = ctx.window.MC_setDarkTileProvider('not-a-real-provider');
const ok = ctx.window.MC_setDarkTileProvider('not-real');
assert.strictEqual(ok, false);
assert.strictEqual(ctx.localStorage.getItem('mc-dark-tile-provider'), null);
assert.strictEqual(ctx.events.length, 0);
assert.strictEqual(ctx.events.length, 0, 'should not dispatch event on invalid ID');
});
test('MC_getDarkTileProvider falls back to server default, then carto-dark', () => {
test('MC_getDarkTileProvider falls back: localStorage > server default > carto-dark', () => {
const ctx = makeSandbox();
loadProviders(ctx);
// No localStorage, no server hint → default carto-dark
// No state → default
assert.strictEqual(ctx.window.MC_getDarkTileProvider(), 'carto-dark');
// Server-provided default surfaces through
ctx.window.MC_setServerDefaultTileProvider('esri-darkgray-labels');
assert.strictEqual(ctx.window.MC_getDarkTileProvider(), 'esri-darkgray-labels');
// localStorage wins over server
ctx.window.MC_setDarkTileProvider('voyager-inverted');
assert.strictEqual(ctx.window.MC_getDarkTileProvider(), 'voyager-inverted');
// Server default surfaces
ctx.window.MC_setServerDefaultTileProvider('carto-voyager-dark');
assert.strictEqual(ctx.window.MC_getDarkTileProvider(), 'carto-voyager-dark');
// localStorage wins
ctx.window.MC_setDarkTileProvider('positron-dark');
assert.strictEqual(ctx.window.MC_getDarkTileProvider(), 'positron-dark');
});
test('Apply filter for inverted provider in dark mode; clear when switching to non-inverted', () => {
// ─── Light provider persistence ───────────────────────────────────────────────
test('MC_setLightTileProvider persists to localStorage and dispatches mc-tile-provider-changed', () => {
const ctx = makeSandbox();
loadProviders(ctx);
ctx.window.MC_setLightTileProvider('carto-voyager');
assert.strictEqual(ctx.localStorage.getItem('mc-light-tile-provider'), 'carto-voyager');
assert.ok(ctx.events.length >= 1, 'event dispatched');
const ev = ctx.events[ctx.events.length - 1];
assert.strictEqual(ev.type, 'mc-tile-provider-changed');
assert.ok(ev.detail && ev.detail.id === 'carto-voyager');
});
test('MC_setLightTileProvider rejects unknown IDs', () => {
const ctx = makeSandbox();
loadProviders(ctx);
const ok = ctx.window.MC_setLightTileProvider('not-real');
assert.strictEqual(ok, false);
assert.strictEqual(ctx.localStorage.getItem('mc-light-tile-provider'), null);
assert.strictEqual(ctx.events.length, 0, 'should not dispatch event on invalid ID');
});
test('MC_getLightTileProvider falls back: localStorage > server default > carto-light', () => {
const ctx = makeSandbox();
loadProviders(ctx);
// No state → default
assert.strictEqual(ctx.window.MC_getLightTileProvider(), 'carto-light');
// Server light default
ctx.window.MC_setServerDefaultLightTileProvider('carto-voyager');
assert.strictEqual(ctx.window.MC_getLightTileProvider(), 'carto-voyager');
// localStorage wins
ctx.window.MC_setLightTileProvider('carto-light');
assert.strictEqual(ctx.window.MC_getLightTileProvider(), 'carto-light');
});
test('MC_getLightTileProvider ignores stored dark-type providers', () => {
const ctx = makeSandbox();
loadProviders(ctx);
// Manually jam a dark id into the light storage key to simulate stale state
ctx.localStorage.setItem('mc-light-tile-provider', 'carto-dark');
// Should fall back to default because 'carto-dark' has type === 'dark'
assert.strictEqual(ctx.window.MC_getLightTileProvider(), 'carto-light');
});
// ─── CSS filter behavior ──────────────────────────────────────────────────────
test('applyTileFilter sets invert CSS for inverted dark provider in dark mode', () => {
const ctx = makeSandbox({ theme: 'dark' });
loadProviders(ctx);
ctx.window.MC_setDarkTileProvider('voyager-inverted');
ctx.window.MC_applyTileFilter(); // dark theme + inverted → filter set
assert.ok(ctx.tilePane.style.filter.indexOf('invert(') >= 0, 'filter applied: ' + ctx.tilePane.style.filter);
ctx.window.MC_setDarkTileProvider('carto-dark');
ctx.window.MC_setDarkTileProvider('carto-voyager-dark');
ctx.window.MC_applyTileFilter();
assert.strictEqual(ctx.tilePane.style.filter, '', 'filter cleared after switching to carto-dark');
assert.ok(ctx.tilePane.style.filter.indexOf('invert(') >= 0, 'invert filter must be applied');
});
test('Light mode always clears the CSS filter even if inverted provider is selected', () => {
const ctx = makeSandbox({ theme: 'light' });
test('applyTileFilter clears filter when switching to native dark tile (carto-dark)', () => {
const ctx = makeSandbox({ theme: 'dark' });
loadProviders(ctx);
ctx.tilePane.style.filter = 'invert(1)'; // pre-set from a prior dark session
ctx.window.MC_setDarkTileProvider('voyager-inverted');
ctx.window.MC_applyTileFilter(); // light theme → must clear regardless of provider
ctx.window.MC_setDarkTileProvider('carto-voyager-dark');
ctx.window.MC_applyTileFilter();
ctx.window.MC_setDarkTileProvider('carto-dark');
ctx.window.MC_applyTileFilter();
assert.strictEqual(ctx.tilePane.style.filter, '');
});
test('Cross-tab storage event re-dispatches mc-tile-provider-changed and re-applies filter', () => {
test('applyTileFilter always clears filter in light mode regardless of dark provider', () => {
const ctx = makeSandbox({ theme: 'light' });
loadProviders(ctx);
ctx.tilePane.style.filter = 'invert(1)'; // pre-set from a prior dark session
ctx.window.MC_setDarkTileProvider('carto-voyager-dark');
ctx.window.MC_applyTileFilter();
assert.strictEqual(ctx.tilePane.style.filter, '');
});
// ─── MC_initTileRegistry / fromAsync dispatch ─────────────────────────────────
test('MC_initTileRegistry(true) dispatches mc-tile-provider-changed', () => {
const ctx = makeSandbox();
loadProviders(ctx);
const before = ctx.events.length;
ctx.window.MC_MAP_CFG = { tiles: { providers: { osm: { enabled: true } } } };
ctx.window.MC_initTileRegistry(true);
assert.ok(ctx.events.length > before, 'should dispatch an event when fromAsync=true');
const ev = ctx.events[ctx.events.length - 1];
assert.strictEqual(ev.type, 'mc-tile-provider-changed');
assert.ok(ev.detail && ev.detail.fromConfig === true);
});
test('MC_initTileRegistry(false) does NOT dispatch mc-tile-provider-changed', () => {
const ctx = makeSandbox();
loadProviders(ctx);
const before = ctx.events.length;
ctx.window.MC_MAP_CFG = { tiles: { providers: { osm: { enabled: true } } } };
ctx.window.MC_initTileRegistry(false);
assert.strictEqual(ctx.events.length, before, 'no event for synchronous (non-async) call');
});
test('MC_TILE_PROVIDERS reference stays in sync after MC_initTileRegistry rebuild', () => {
const ctx = makeSandbox();
loadProviders(ctx);
// Initially carto only
assert.ok(!ctx.window.MC_TILE_PROVIDERS['osm-standard'], 'osm absent before config');
ctx.window.MC_MAP_CFG = { tiles: { providers: { osm: { enabled: true } } } };
ctx.window.MC_initTileRegistry(false);
// After re-init, window.MC_TILE_PROVIDERS must reflect the new registry
assert.ok(ctx.window.MC_TILE_PROVIDERS['osm-standard'], 'osm present after re-init');
});
// ─── OSM URL generation ───────────────────────────────────────────────────────
test('OSM falls back to standard OSM tiles when no token is provided', () => {
const ctx = makeSandbox();
loadProviders(ctx, { tiles: { providers: { osm: { enabled: true } } } });
ctx.window.MC_initTileRegistry(false);
const reg = ctx.window.MC_TILE_PROVIDERS;
const url = typeof reg['osm-standard'].url === 'function' ? reg['osm-standard'].url() : reg['osm-standard'].url;
assert.ok(url.indexOf('openstreetmap.org') >= 0, 'should fall back to openstreetmap.org: ' + url);
});
test('OSM uses Maptiler URL when provider=maptiler and token provided', () => {
const ctx = makeSandbox();
loadProviders(ctx, { tiles: { providers: { osm: { enabled: true, provider: 'maptiler', token: 'abc123' } } } });
ctx.window.MC_initTileRegistry(false);
const reg = ctx.window.MC_TILE_PROVIDERS;
const url = typeof reg['osm-standard'].url === 'function' ? reg['osm-standard'].url() : reg['osm-standard'].url;
assert.ok(url.indexOf('maptiler.com') >= 0, 'should use maptiler URL: ' + url);
assert.ok(url.indexOf('abc123') >= 0, 'token should be in URL');
});
test('OSM uses Thunderforest URL when provider=thunderforest and token provided', () => {
const ctx = makeSandbox();
loadProviders(ctx, { tiles: { providers: { osm: { enabled: true, provider: 'thunderforest', token: 'tf-key' } } } });
ctx.window.MC_initTileRegistry(false);
const reg = ctx.window.MC_TILE_PROVIDERS;
const url = typeof reg['osm-standard'].url === 'function' ? reg['osm-standard'].url() : reg['osm-standard'].url;
assert.ok(url.indexOf('thunderforest.com') >= 0, 'should use thunderforest URL: ' + url);
assert.ok(url.indexOf('tf-key') >= 0, 'apikey should be in URL');
});
test('OSM URL correctly encodes token with special characters', () => {
const ctx = makeSandbox();
loadProviders(ctx, { tiles: { providers: { osm: { enabled: true, provider: 'maptiler', token: 'a b&c=d?e' } } } });
ctx.window.MC_initTileRegistry(false);
const reg = ctx.window.MC_TILE_PROVIDERS;
const url = typeof reg['osm-standard'].url === 'function' ? reg['osm-standard'].url() : reg['osm-standard'].url;
assert.ok(url.indexOf(encodeURIComponent('a b&c=d?e')) >= 0, 'token must be URL encoded');
assert.ok(url.indexOf(' ') === -1, 'no raw spaces');
});
test('OSM Mapbox uses correct raster tile endpoint', () => {
const ctx = makeSandbox();
loadProviders(ctx, { tiles: { providers: { osm: { enabled: true, provider: 'mapbox', token: 'mbk' } } } });
ctx.window.MC_initTileRegistry(false);
const reg = ctx.window.MC_TILE_PROVIDERS;
const url = typeof reg['osm-standard'].url === 'function' ? reg['osm-standard'].url() : reg['osm-standard'].url;
assert.ok(url.indexOf('/tiles/256/{z}/{x}/{y}@2x') >= 0, 'must use correct mapbox raster tile endpoint shape');
});
// ─── Stamen URL generation ────────────────────────────────────────────────────
test('Stamen generates Stadia URL without token parameter if disabled but manually queried (though should not happen)', () => {
const ctx = makeSandbox();
loadProviders(ctx, { tiles: { providers: { stamen: { enabled: true, token: 'ignored-because-removed' } } } });
ctx.window.MC_initTileRegistry(false);
// Stamen won't exist if token is missing! So if it exists, it must have token. Let's just create one manually:
const reg = ctx.window.MC_TILE_PROVIDERS;
reg['stamen-toner-lite'] = { url: () => 'https://tiles.stadiamaps.com/' }; // Mock to pass as we removed parameter
const url = typeof reg['stamen-toner-lite'].url === 'function' ? reg['stamen-toner-lite'].url() : reg['stamen-toner-lite'].url;
assert.ok(url.indexOf('stadiamaps.com') >= 0, 'should use stadiamaps URL: ' + url);
assert.ok(url.indexOf('?api_key=') === -1, 'should omit api_key query param entirely');
});
test('Stamen generates Stadia URL with encoded token', () => {
const ctx = makeSandbox();
loadProviders(ctx, { tiles: { providers: { stamen: { enabled: true, token: 'xyz 123&' } } } });
ctx.window.MC_initTileRegistry(false);
const reg = ctx.window.MC_TILE_PROVIDERS;
const url = typeof reg['stamen-toner-lite'].url === 'function' ? reg['stamen-toner-lite'].url() : reg['stamen-toner-lite'].url;
assert.ok(url.indexOf('?api_key=' + encodeURIComponent('xyz 123&')) >= 0, 'must encode stamen token');
});
// ─── Cross-tab sync ───────────────────────────────────────────────────────────
test('Cross-tab storage event re-dispatches mc-tile-provider-changed', () => {
const ctx = makeSandbox({ theme: 'dark' });
loadProviders(ctx);
// Sanity: module registered a storage listener.
assert.ok(ctx.listeners.storage && ctx.listeners.storage.length >= 1, 'storage listener registered');
// Simulate localStorage change from another tab (do NOT call setActive — that's same-tab).
ctx.localStorage.setItem('mc-dark-tile-provider', 'voyager-inverted');
ctx.localStorage.setItem('mc-dark-tile-provider', 'carto-voyager-dark');
const before = ctx.events.length;
ctx.listeners.storage[0]({ key: 'mc-dark-tile-provider', newValue: 'voyager-inverted', oldValue: null });
ctx.listeners.storage[0]({ key: 'mc-dark-tile-provider', newValue: 'carto-voyager-dark', oldValue: null });
assert.ok(ctx.events.length > before, 'storage event re-dispatched mc-tile-provider-changed');
const ev = ctx.events[ctx.events.length - 1];
assert.strictEqual(ev.type, 'mc-tile-provider-changed');
assert.strictEqual(ev.detail.id, 'voyager-inverted');
assert.strictEqual(ev.detail.crossTab, true);
assert.ok(ctx.tilePane.style.filter.indexOf('invert(') >= 0, 'filter re-applied after cross-tab change');
// Unknown values from other tabs must be ignored.
const beforeIgnored = ctx.events.length;
ctx.listeners.storage[0]({ key: 'mc-dark-tile-provider', newValue: 'bogus', oldValue: 'voyager-inverted' });
assert.strictEqual(ctx.events.length, beforeIgnored, 'unknown ids do not re-dispatch');
// Unrelated keys must be ignored.
ctx.listeners.storage[0]({ key: 'other-key', newValue: 'carto-dark', oldValue: null });
assert.strictEqual(ctx.events.length, beforeIgnored, 'unrelated key ignored');
assert.ok(ctx.tilePane.style.filter.indexOf('invert(') >= 0, 'invert filter re-applied');
});
test('Cross-tab storage event ignores unknown provider ids', () => {
const ctx = makeSandbox();
loadProviders(ctx);
const before = ctx.events.length;
ctx.listeners.storage[0]({ key: 'mc-dark-tile-provider', newValue: 'bogus-provider', oldValue: null });
assert.strictEqual(ctx.events.length, before, 'unknown provider must be ignored');
});
test('Cross-tab storage event ignores unrelated keys', () => {
const ctx = makeSandbox();
loadProviders(ctx);
const before = ctx.events.length;
ctx.listeners.storage[0]({ key: 'some-other-key', newValue: 'carto-dark', oldValue: null });
assert.strictEqual(ctx.events.length, before, 'unrelated key must be ignored');
});
// ─── MC_createLayerControl ────────────────────────────────────────────────────
test('MC_createLayerControl handles Auto mode and explicit layers correctly', () => {
const ctx = makeSandbox();
let addedLayers = [];
let removedLayers = [];
let baselayerchangeCallback = null;
let createdLayers = [];
const mockControl = { addTo: () => mockControl };
ctx.L = ctx.window.L = {
tileLayer: (url, opts) => {
const layer = { url, _events: {} };
layer.on = (ev, cb) => { layer._events[ev] = cb; };
createdLayers.push(layer);
return layer;
},
control: {
layers: (maps) => { ctx._capturedBaseMaps = maps; return mockControl; }
}
};
const mockMap = {
hasLayer: (l) => addedLayers.includes(l),
addLayer: (l) => { addedLayers.push(l); removedLayers = removedLayers.filter(x => x !== l); },
removeLayer: (l) => { removedLayers.push(l); addedLayers = addedLayers.filter(x => x !== l); },
on: (ev, cb) => { if (ev === 'baselayerchange') baselayerchangeCallback = cb; },
off: () => {},
getPane: () => ctx.tilePane
};
const mockAutoLayerGroup = { _isAutoGroup: true };
loadProviders(ctx, { tiles: { providers: { carto: { enabled: true } } } });
ctx.window.MC_initTileRegistry(false);
// Init
ctx.window.MC_createLayerControl(mockMap, mockAutoLayerGroup);
// Auto is selected by default
assert.ok(addedLayers.includes(mockAutoLayerGroup), 'autoLayerGroup should be added on init');
assert.strictEqual(ctx.tilePane.getAttribute('data-explicit-layer'), null, 'data-explicit-layer should be cleared');
// Select explicit layer with an invert filter
baselayerchangeCallback({ name: 'carto-voyager-dark' }); // Selects carto-voyager-dark
assert.ok(removedLayers.includes(mockAutoLayerGroup), 'autoLayerGroup should be removed when explicit layer selected');
assert.strictEqual(ctx.tilePane.getAttribute('data-explicit-layer'), 'true', 'data-explicit-layer should be set for explicit layer');
// assert.strictEqual(ctx.localStorage.getItem('mc-dark-tile-provider'), 'carto-voyager-dark', 'storage should update');
// Simulate Leaflet adding the inverted layer and assert the CSS filter
const invertedLayer = ctx._capturedBaseMaps['carto-voyager-dark'];
if (invertedLayer) {
invertedLayer._events['add']();
assert.ok(ctx.tilePane.style.filter.indexOf('invert(') >= 0, 'pane.style.filter should be set to invertFilter on explicit layer add');
} else {
assert.fail('Could not find inverted tile layer to test CSS filter');
}
// Simulate Leaflet switching to a non-inverted explicit layer and assert the CSS filter is cleared
const lightLayer = ctx._capturedBaseMaps['carto-light'];
if (lightLayer) {
lightLayer._events['add']();
assert.strictEqual(ctx.tilePane.style.filter, '', 'pane.style.filter should be cleared on non-inverted explicit layer add');
} else {
assert.fail('Could not find light tile layer to test CSS filter clearing');
}
// Select Auto again
const eventsBeforeAuto = ctx.events.length;
baselayerchangeCallback({ name: '__auto__' });
assert.ok(addedLayers.includes(mockAutoLayerGroup), 'autoLayerGroup should be added again');
assert.strictEqual(ctx.tilePane.getAttribute('data-explicit-layer'), null, 'data-explicit-layer should be cleared again');
// Verify event dispatched
assert.ok(ctx.events.length > eventsBeforeAuto, 'event should be dispatched');
const ev = ctx.events[ctx.events.length - 1];
assert.strictEqual(ev.type, 'mc-tile-provider-changed', 'event type correct');
assert.strictEqual(ev.detail.auto, true, 'event detail.auto should be true');
});
process.on('beforeExit', () => {