diff --git a/cmd/server/config.go b/cmd/server/config.go index 4295517b..4b7a6cfc 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -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{"."} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index d1fef5be..9d41f03e 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -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, }) } diff --git a/cmd/server/types.go b/cmd/server/types.go index 630e773b..42b19c19 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -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 ─────────────────────────────────────────────────────────────── diff --git a/config.example.json b/config.example.json index 06dc9dd0..570f5566 100644 --- a/config.example.json +++ b/config.example.json @@ -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.", diff --git a/docs/deployment.md b/docs/deployment.md index 2d1bdb61..a7c15a0c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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. --- diff --git a/public/customize-v2.js b/public/customize-v2.js index a49d2208..7eb83107 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -1390,7 +1390,7 @@ '

Re-show first-visit gesture discoverability hints (swipe rows, swipe tabs, edge-swipe drawer, pull-to-refresh).

' + '' + _renderChannelsShowEncryptedToggle() + - _renderDarkTileProviderSelector() + + _renderTileProviderSelector() + ''; } @@ -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 ''; - }).join(''); - return '

Dark Map Tiles

' + - '

Choose the dark-mode basemap. Light mode is unaffected. Inverted variants apply a CSS filter for higher contrast.

' + - '
' + - '' + + optionsLight + '
'; + } + + if (darkIds.length > 0) { + var optionsDark = darkIds.map(function (id) { + var label = reg[id].label || id; + var sel = id === activeDark ? ' selected' : ''; + return ''; + }).join(''); + html += '
' + + '
'; + } + + 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) { diff --git a/public/live.css b/public/live.css index f4e57204..70554af0 100644 --- a/public/live.css +++ b/public/live.css @@ -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; } diff --git a/public/live.js b/public/live.js index 7ca7a656..3a9e6106 100644 --- a/public/live.js +++ b/public/live.js @@ -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) diff --git a/public/map-tile-providers.js b/public/map-tile-providers.js index 325d6941..6266b81f 100644 --- a/public/map-tile-providers.js +++ b/public/map-tile-providers.js @@ -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 = ' Auto'; + 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 + 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 { diff --git a/public/map.js b/public/map.js index deba6ebf..cfcbb4bb 100644 --- a/public/map.js +++ b/public/map.js @@ -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 = []; diff --git a/public/roles.js b/public/roles.js index 5d033537..7242fac7 100644 --- a/public/roles.js +++ b/public/roles.js @@ -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; diff --git a/public/style.css b/public/style.css index afe654be..9b0ce228 100644 --- a/public/style.css +++ b/public/style.css @@ -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%; diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 415cb924..57e8483c 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -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)}`); diff --git a/test-issue-1420-tile-providers.js b/test-issue-1420-tile-providers.js index e4f23452..c70edb36 100644 --- a/test-issue-1420-tile-providers.js +++ b/test-issue-1420-tile-providers.js @@ -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', () => {