mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-31 20:14:40 +00:00
# feat(#1420): dark-tile provider picker in customizer (4 variants) Closes #1420. ## What Operator pick: don't force a single dark-tile choice on everyone. Wire 4 candidates into the customizer + server config so users can choose which dark basemap they want, with per-browser persistence. ## Providers shipped | ID | Source | Filter | |---|---|---| | `carto-dark` (default) | `https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png` | none | | `esri-darkgray-labels` | Esri Dark Gray Base + Reference (two stacked layers) | none | | `voyager-inverted` | Carto Voyager + CSS `invert(1) hue-rotate(180deg) brightness(0.9) contrast(1.05)` on `.leaflet-tile-pane` | applied in dark, cleared in light | | `positron-inverted` | Carto Positron + same CSS invert | applied in dark, cleared in light | No new dependencies — all providers are URL-only. ## Architecture - **`public/map-tile-providers.js`** — registry + 5 public helpers (`MC_TILE_PROVIDERS`, `MC_setDarkTileProvider`, `MC_getDarkTileProvider`, `MC_setServerDefaultTileProvider`, `MC_applyTileFilter`). Persists to `localStorage['mc-dark-tile-provider']`. Dispatches `mc-tile-provider-changed` on user pick. - **`public/map.js` / `public/live.js`** — resolve the active dark provider via the registry, manage the Esri labels overlay lifecycle (add when needed, remove cleanly so we don't leak layers on repeated theme toggles), and apply/clear the CSS filter on `.leaflet-tile-pane`. Listen for both `data-theme` mutations AND `mc-tile-provider-changed`. - **`public/customize-v2.js`** — new "Dark Map Tiles" dropdown in the Display tab. On change, calls `MC_setDarkTileProvider(id)`; the maps re-render live without reload. - **`public/roles.js`** — hydrates the server default via `MC_setServerDefaultTileProvider` from `/api/config/client`. - **Server (`cmd/server/`)** — new `mapDarkTileProvider` string on `Config` + surfaced in `ClientConfigResponse`. Default empty → client uses `carto-dark`. - **`config.example.json`** — documents the new field with all allowed values. ## Behavior guarantees (from the acceptance criteria) - ✅ Light mode is **completely unchanged** — `_resolveTileUrl(false)` short-circuits to `TILE_LIGHT` with no filter and no overlay logic. - ✅ Switching dark→light always clears the CSS filter, even if an inverted provider remains selected (`MC_applyTileFilter` is called on every theme change and early-returns to `style.filter = ''` when not dark). - ✅ Switching light→dark with an inverted provider re-applies the filter. - ✅ Attribution is updated per provider (Esri credit for Esri, CartoDB credit for the others); the Leaflet attribution control is refreshed. - ✅ Esri uses two stacked layers (base + reference labels). The reference layer is added/removed cleanly so repeat toggles do not leak. - ✅ Customizer change → immediate re-render, no reload. Uses the same "live setting + persist + dispatch event" pattern as cb-presets (#1361). ## TDD - Red commit: `148b71c3` — `test(#1420): add failing tests for dark-tile provider registry (red)` — 6/7 assertions fail (stub only returns nulls). - Green commit: `49ffb230` — `feat(#1420): dark-tile provider picker — 4 variants wired into customizer` — 7/7 pass. ## Tests `test-issue-1420-tile-providers.js` (wired into `test-all.sh` and `.github/workflows/deploy.yml` JS-unit step): ``` ── #1420 Dark-tile provider registry ── ✅ MC_TILE_PROVIDERS has all 4 IDs with url + attribution ✅ Inverted providers have non-null invertFilter; non-inverted have null ✅ MC_setDarkTileProvider persists to localStorage and dispatches mc-tile-provider-changed ✅ MC_setDarkTileProvider rejects unknown IDs (no persistence, no dispatch) ✅ MC_getDarkTileProvider falls back to server default, then carto-dark ✅ Apply filter for inverted provider in dark mode; clear when switching to non-inverted ✅ Light mode always clears the CSS filter even if inverted provider is selected 7 passed, 0 failed ``` `cd cmd/server && go build ./... && go vet ./...` — clean. ## CDP verification Not run in this PR — the sandbox does not have a Chrome CDP endpoint reachable, and staging cannot exercise this code path until this branch is deployed. The issue body's "CDP-verified candidate set" table covers prior provider-URL validation; the new code path (registry lookup + filter swap + Esri overlay lifecycle) is covered by the unit tests above. **Recommend operator run a quick manual verification on staging post-deploy:** dark mode → open customizer → cycle through all 4 providers, confirm tiles render and the CSS filter is applied for `voyager-inverted` / `positron-inverted` (verify via `getComputedStyle(document.querySelector('.leaflet-tile-pane')).filter`). ## Files touched - `public/map-tile-providers.js` (new) - `public/map.js`, `public/live.js`, `public/customize-v2.js`, `public/roles.js`, `public/index.html` - `cmd/server/config.go`, `cmd/server/routes.go`, `cmd/server/types.go` - `config.example.json` - `test-issue-1420-tile-providers.js` (new), `test-all.sh`, `.github/workflows/deploy.yml` - `.eslintrc.json` (register new `MC_*` globals) --------- Co-authored-by: openclaw <bot@openclaw.local>
This commit is contained in:
@@ -59,6 +59,12 @@
|
||||
"SlideOver": "readonly",
|
||||
"TILE_DARK": "readonly",
|
||||
"TILE_LIGHT": "readonly",
|
||||
"MC_TILE_PROVIDERS": "readonly",
|
||||
"MC_setDarkTileProvider": "readonly",
|
||||
"MC_getDarkTileProvider": "readonly",
|
||||
"MC_setServerDefaultTileProvider": "readonly",
|
||||
"MC_applyTileFilter": "readonly",
|
||||
"MC_DARK_TILE_DEFAULT": "readonly",
|
||||
"TYPE_COLORS": "readonly",
|
||||
"TableResponsive": "readonly",
|
||||
"TableSort": "readonly",
|
||||
|
||||
@@ -120,6 +120,7 @@ jobs:
|
||||
node test-issue-1418-spider-fan.js
|
||||
node test-issue-1418-deeplink-hops-channels.js
|
||||
node test-issue-1418-polish-review.js
|
||||
node test-issue-1420-tile-providers.js
|
||||
node test-live.js
|
||||
|
||||
- name: 🧹 Frontend lint (eslint no-undef) — issue #1342
|
||||
|
||||
@@ -92,6 +92,13 @@ 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.
|
||||
MapDarkTileProvider string `json:"mapDarkTileProvider,omitempty"`
|
||||
|
||||
// ObserverBlacklist is a list of observer public keys to exclude from API
|
||||
// responses (defense in depth — ingestor drops at ingest, server filters
|
||||
// any that slipped through from a prior unblocked window).
|
||||
|
||||
@@ -346,6 +346,7 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
|
||||
PropagationBufferMs: float64(s.cfg.PropagationBufferMs()),
|
||||
Timestamps: s.cfg.GetTimestampConfig(),
|
||||
DebugAffinity: s.cfg.DebugAffinity,
|
||||
MapDarkTileProvider: s.cfg.MapDarkTileProvider,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -988,6 +988,9 @@ 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"`
|
||||
}
|
||||
|
||||
// ─── IATA Coords ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
"observer": "#8b5cf6",
|
||||
"_comment": "Marker/badge colors per node role. Used on map, nodes list, and live feed."
|
||||
},
|
||||
"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.",
|
||||
"home": {
|
||||
"heroTitle": "CoreScope",
|
||||
"heroSubtitle": "Find your nodes to start monitoring them.",
|
||||
|
||||
@@ -1256,9 +1256,31 @@
|
||||
'<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Gesture Hints</p>' +
|
||||
'<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>' +
|
||||
_renderDarkTileProviderSelector() +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ── #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() {
|
||||
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 +
|
||||
'</select></div>';
|
||||
}
|
||||
|
||||
function _renderHome() {
|
||||
var eff = _getEffective();
|
||||
var h = eff.home || {};
|
||||
@@ -1808,6 +1830,16 @@
|
||||
});
|
||||
});
|
||||
|
||||
// #1420 Dark-tile provider dropdown — persists + fires mc-tile-provider-changed
|
||||
container.querySelectorAll('[data-cv2-dark-tile-provider]').forEach(function (sel) {
|
||||
sel.addEventListener('change', function () {
|
||||
var id = sel.value;
|
||||
if (typeof window.MC_setDarkTileProvider === 'function') {
|
||||
window.MC_setDarkTileProvider(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Preset buttons
|
||||
container.querySelectorAll('.cust-preset-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=__BUST__"></script>
|
||||
<script src="map-tile-providers.js?v=__BUST__"></script>
|
||||
<script src="cb-presets.js?v=__BUST__"></script>
|
||||
<script src="customize-v2.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=__BUST__"></script>
|
||||
|
||||
+46
-2
@@ -1171,15 +1171,59 @@
|
||||
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
let tileLayer = L.tileLayer(isDark ? TILE_DARK : TILE_LIGHT, { maxZoom: 19 }).addTo(map);
|
||||
// #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 || {};
|
||||
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,
|
||||
attribution: p.attribution || '© OpenStreetMap © CartoDB',
|
||||
refUrl: p.refUrl || null
|
||||
};
|
||||
}
|
||||
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);
|
||||
} else {
|
||||
_liveDarkRefLayer.setUrl(r.refUrl);
|
||||
}
|
||||
} else if (_liveDarkRefLayer) {
|
||||
map.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) {
|
||||
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);
|
||||
if (isDark && _liveInitTile.refUrl) {
|
||||
_liveDarkRefLayer = L.tileLayer(_liveInitTile.refUrl, { maxZoom: 19, attribution: _liveInitTile.attribution }).addTo(map);
|
||||
}
|
||||
if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter();
|
||||
|
||||
// Swap tiles when theme changes
|
||||
const _themeObs = new MutationObserver(function () {
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
tileLayer.setUrl(dark ? TILE_DARK : TILE_LIGHT);
|
||||
_liveSyncDarkTiles(dark);
|
||||
});
|
||||
_themeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
// #1420 — re-render on customizer change.
|
||||
window.addEventListener('mc-tile-provider-changed', function () {
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
_liveSyncDarkTiles(dark);
|
||||
});
|
||||
L.control.zoom({ position: 'topright' }).addTo(map);
|
||||
|
||||
nodesLayer = L.layerGroup().addTo(map);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/* map-tile-providers.js — Dark-tile provider registry & runtime switcher (#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'.
|
||||
* - MC_applyTileFilter() applies/clears the CSS filter on
|
||||
* `.leaflet-tile-pane` based on current theme + selected provider.
|
||||
*
|
||||
* No new deps — URL-only providers. Light mode is unchanged.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var STORAGE_KEY = 'mc-dark-tile-provider';
|
||||
var DEFAULT_ID = 'carto-dark';
|
||||
var EVENT_NAME = 'mc-tile-provider-changed';
|
||||
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;
|
||||
|
||||
// ── 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
|
||||
}
|
||||
};
|
||||
|
||||
function _hasId(id) {
|
||||
return typeof id === 'string' && Object.prototype.hasOwnProperty.call(REGISTRY, id);
|
||||
}
|
||||
|
||||
function _isDark() {
|
||||
try {
|
||||
var attr = document.documentElement.getAttribute('data-theme');
|
||||
if (attr === 'dark') return true;
|
||||
if (attr === 'light') return false;
|
||||
return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
function getActiveId() {
|
||||
try {
|
||||
var stored = window.localStorage && window.localStorage.getItem(STORAGE_KEY);
|
||||
if (_hasId(stored)) return stored;
|
||||
} catch (_) { /* localStorage may be disabled */ }
|
||||
if (_hasId(_serverDefault)) return _serverDefault;
|
||||
return DEFAULT_ID;
|
||||
}
|
||||
|
||||
function setActive(id) {
|
||||
if (!_hasId(id)) return false;
|
||||
try {
|
||||
if (window.localStorage) window.localStorage.setItem(STORAGE_KEY, id);
|
||||
} catch (_) { /* swallow quota / disabled */ }
|
||||
var detail = { id: id, provider: REGISTRY[id] };
|
||||
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.
|
||||
applyTileFilter();
|
||||
return true;
|
||||
}
|
||||
|
||||
function setServerDefault(id) {
|
||||
if (_hasId(id)) _serverDefault = id;
|
||||
}
|
||||
|
||||
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();
|
||||
var p = REGISTRY[id];
|
||||
pane.style.filter = (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_getDarkTileProvider = getActiveId;
|
||||
window.MC_setServerDefaultTileProvider = setServerDefault;
|
||||
window.MC_applyTileFilter = applyTileFilter;
|
||||
|
||||
// ── 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 (!_hasId(e.newValue)) return;
|
||||
var detail = { id: e.newValue, provider: REGISTRY[e.newValue], crossTab: true };
|
||||
try {
|
||||
var ev = (typeof CustomEvent === 'function')
|
||||
? new CustomEvent(EVENT_NAME, { detail: detail })
|
||||
: { type: EVENT_NAME, detail: detail };
|
||||
window.dispatchEvent(ev);
|
||||
} catch (_) { /* dispatch optional */ }
|
||||
applyTileFilter();
|
||||
});
|
||||
} catch (_) { /* addEventListener may not exist in some envs */ }
|
||||
})();
|
||||
+47
-3
@@ -256,16 +256,60 @@
|
||||
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const tileLayer = L.tileLayer(isDark ? TILE_DARK : TILE_LIGHT, {
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
// #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 || {};
|
||||
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,
|
||||
attribution: p.attribution || '© OpenStreetMap © CartoDB',
|
||||
refUrl: p.refUrl || null
|
||||
};
|
||||
}
|
||||
function _syncDarkTiles(dark) {
|
||||
const r = _resolveTileUrl(dark);
|
||||
tileLayer.setUrl(r.url);
|
||||
if (tileLayer.options) tileLayer.options.attribution = r.attribution;
|
||||
// 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);
|
||||
} else {
|
||||
_darkRefLayer.setUrl(r.refUrl);
|
||||
}
|
||||
} else if (_darkRefLayer) {
|
||||
map.removeLayer(_darkRefLayer);
|
||||
_darkRefLayer = null;
|
||||
}
|
||||
if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter();
|
||||
if (map.attributionControl) {
|
||||
try { map.attributionControl._update && map.attributionControl._update(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
const _initTile = _resolveTileUrl(isDark);
|
||||
const tileLayer = L.tileLayer(_initTile.url, {
|
||||
attribution: _initTile.attribution,
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
if (isDark && _initTile.refUrl) {
|
||||
_darkRefLayer = L.tileLayer(_initTile.refUrl, { maxZoom: 19, attribution: _initTile.attribution }).addTo(map);
|
||||
}
|
||||
if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter();
|
||||
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);
|
||||
tileLayer.setUrl(dark ? TILE_DARK : TILE_LIGHT);
|
||||
_syncDarkTiles(dark);
|
||||
});
|
||||
_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 () {
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
_syncDarkTiles(dark);
|
||||
});
|
||||
|
||||
// Save position on move
|
||||
map.on('moveend', () => {
|
||||
|
||||
@@ -357,6 +357,10 @@
|
||||
if (cfg.tiles.dark) window.TILE_DARK = cfg.tiles.dark;
|
||||
if (cfg.tiles.light) window.TILE_LIGHT = cfg.tiles.light;
|
||||
}
|
||||
// #1420 — server default for dark-tile provider picker.
|
||||
if (typeof cfg.mapDarkTileProvider === 'string' && typeof window.MC_setServerDefaultTileProvider === 'function') {
|
||||
window.MC_setServerDefaultTileProvider(cfg.mapDarkTileProvider);
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -36,6 +36,7 @@ node test-issue-1418-cb-preset-ramp.js
|
||||
node test-issue-1418-spider-fan.js
|
||||
node test-issue-1418-deeplink-hops-channels.js
|
||||
node test-issue-1418-polish-review.js
|
||||
node test-issue-1420-tile-providers.js
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════"
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/* test-issue-1420-tile-providers.js — Dark-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.
|
||||
*/
|
||||
'use strict';
|
||||
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); }
|
||||
}
|
||||
|
||||
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]; },
|
||||
_raw: store
|
||||
};
|
||||
}
|
||||
|
||||
function makeSandbox(opts) {
|
||||
opts = opts || {};
|
||||
const events = [];
|
||||
const listeners = {};
|
||||
const tilePane = { style: { filter: '' } };
|
||||
const ctx = {
|
||||
console,
|
||||
setTimeout, clearTimeout,
|
||||
JSON, Date, Math, Object, Array, String, Number, Boolean,
|
||||
localStorage: makeStorage(),
|
||||
document: {
|
||||
documentElement: { getAttribute: () => opts.theme || 'dark' },
|
||||
querySelector: (sel) => sel === '.leaflet-tile-pane' ? tilePane : null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: () => {},
|
||||
},
|
||||
window: {
|
||||
addEventListener: (type, fn) => { (listeners[type] = listeners[type] || []).push(fn); },
|
||||
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.listeners = listeners;
|
||||
ctx.tilePane = tilePane;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function loadProviders(ctx) {
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'map-tile-providers.js'), 'utf8');
|
||||
vm.runInContext(src, ctx);
|
||||
}
|
||||
|
||||
console.log('── #1420 Dark-tile provider registry ──');
|
||||
|
||||
test('MC_TILE_PROVIDERS has all 4 IDs with url + attribution', () => {
|
||||
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}');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Inverted providers have non-null invertFilter; non-inverted have null', () => {
|
||||
const ctx = makeSandbox();
|
||||
loadProviders(ctx);
|
||||
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);
|
||||
});
|
||||
|
||||
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');
|
||||
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');
|
||||
});
|
||||
|
||||
test('MC_setDarkTileProvider rejects unknown IDs (no persistence, no dispatch)', () => {
|
||||
const ctx = makeSandbox();
|
||||
loadProviders(ctx);
|
||||
const ok = ctx.window.MC_setDarkTileProvider('not-a-real-provider');
|
||||
assert.strictEqual(ok, false);
|
||||
assert.strictEqual(ctx.localStorage.getItem('mc-dark-tile-provider'), null);
|
||||
assert.strictEqual(ctx.events.length, 0);
|
||||
});
|
||||
|
||||
test('MC_getDarkTileProvider falls back to server default, then carto-dark', () => {
|
||||
const ctx = makeSandbox();
|
||||
loadProviders(ctx);
|
||||
// No localStorage, no server hint → default carto-dark
|
||||
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');
|
||||
});
|
||||
|
||||
test('Apply filter for inverted provider in dark mode; clear when switching to non-inverted', () => {
|
||||
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_applyTileFilter();
|
||||
assert.strictEqual(ctx.tilePane.style.filter, '', 'filter cleared after switching to carto-dark');
|
||||
});
|
||||
|
||||
test('Light mode always clears the CSS filter even if inverted provider is selected', () => {
|
||||
const ctx = makeSandbox({ theme: 'light' });
|
||||
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
|
||||
assert.strictEqual(ctx.tilePane.style.filter, '');
|
||||
});
|
||||
|
||||
test('Cross-tab storage event re-dispatches mc-tile-provider-changed and re-applies filter', () => {
|
||||
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');
|
||||
const before = ctx.events.length;
|
||||
ctx.listeners.storage[0]({ key: 'mc-dark-tile-provider', newValue: 'voyager-inverted', 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');
|
||||
});
|
||||
|
||||
process.on('beforeExit', () => {
|
||||
console.log('');
|
||||
console.log(' ' + passed + ' passed, ' + failed + ' failed');
|
||||
if (failed) process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user