mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-05 17:41:18 +00:00
777f77a451
# 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>
136 lines
5.6 KiB
JavaScript
136 lines
5.6 KiB
JavaScript
/* 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 */ }
|
|
})();
|