diff --git a/docs/en_US/editgrid.rst b/docs/en_US/editgrid.rst
index b72e3aed073..65ddfc39f43 100644
--- a/docs/en_US/editgrid.rst
+++ b/docs/en_US/editgrid.rst
@@ -101,6 +101,11 @@ properties of the geometries directly in map, just click the specific geometry:
viewer will render geometries with the same SRID in the map. If SRID=4326 the
OSM tile layer will be added into the map.
+ - *Custom tile provider:* A custom map tile provider ({z}/{x}/{y} URL template,
+ in EPSG:3857, EPSG:4326 or EPSG:3395) can be configured as a base layer via
+ *Preferences > Query Tool > Geometry Viewer*. When configured, it is
+ selected as the default base layer.
+
- *Data size:* For performance reasons, the viewer will render no more than
100000 geometries, totaling up to 20MB.
diff --git a/docs/en_US/preferences.rst b/docs/en_US/preferences.rst
index 0fcf6413bd5..1d93a17c6b8 100644
--- a/docs/en_US/preferences.rst
+++ b/docs/en_US/preferences.rst
@@ -635,6 +635,33 @@ a graphical EXPLAIN.
* When the *Verbose output?* switch is set to *True*, graphical explain details
will include extended information about the query execution plan.
+Use the fields on the *Geometry Viewer* panel to configure a custom map tile
+provider used as a base layer when viewing geometry data.
+
+* Use the *Custom tile provider URL* field to specify the URL template of a
+ custom XYZ tile provider, e.g.
+ ``https://myserver.example.com/tiles/{z}/{x}/{y}.png``. The template must
+ contain the ``{x}``, ``{y}`` and ``{z}`` placeholders, and may contain
+ ``{s}`` for subdomains (a, b, c). Leave the field empty to disable the
+ custom tile provider.
+
+* Use the *Custom tile provider name* field to specify the display name of
+ the custom tile provider in the layer selector of the Geometry Viewer.
+
+* Use the *Custom tile provider CRS* field to specify the coordinate
+ reference system of the custom tile provider. If it is not EPSG:3857
+ (Web Mercator), the built-in tile layers will be hidden as they cannot be
+ mixed with other coordinate systems.
+
+* Use the *Custom tile provider attribution* field to specify the
+ attribution text shown on the map. It may contain HTML links.
+
+* Use the *Custom tile provider max zoom* field to specify the maximum zoom
+ level of the custom tile provider.
+
+When a custom tile provider is configured, it is selected as the default
+base layer of the Geometry Viewer.
+
.. image:: images/preferences_graph_visualiser.png
:alt: Preferences sqleditor graph visualiser section
:align: center
diff --git a/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx b/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx
index 122fdf9d2bb..170cb6ec7af 100644
--- a/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx
+++ b/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx
@@ -86,6 +86,7 @@ export function prepareSubnodeData(node, subNode, nodeData, preferencesStore) {
// Ensure type is set after specific handling
element.type = type;
+ element.controlProps = {...(element.control_props ?? {}), ...element.controlProps};
if (type === 'selectFile') {
// Binary Path specific handling
note = gettext('Enter the directory in which the psql, pg_dump, pg_dumpall, and pg_restore utilities can be found for the corresponding database server version. The default path will be used for server versions that do not have a path specified.');
@@ -96,13 +97,16 @@ export function prepareSubnodeData(node, subNode, nodeData, preferencesStore) {
element.canEdit = false;
element.editable = false;
element.disabled = true; // Binary paths are managed in a collection, not directly editable here
- fieldValues[element.id] = JSON.parse(element.value);
+ try {
+ fieldValues[element.id] = JSON.parse(element.value);
+ } catch {
+ fieldValues[element.id] = [];
+ }
if (!addBinaryPathNote) { // Add note only once for binary path section
fieldItems.push(...getNoteField(node, subNode, nodeData, note));
addBinaryPathNote = true;
}
} else if (type === 'select') {
- element.controlProps = element.control_props ?? {};
fieldValues[element.id] = element.value;
if (element.name === 'theme') {
@@ -228,7 +232,7 @@ export function prepareSubnodeData(node, subNode, nodeData, preferencesStore) {
element.editable = false;
const storedValue = preferencesStore.getPreferences(node.label.toLowerCase(), element.name)?.value;
- fieldValues[element.id] = storedValue || element.value;
+ fieldValues[element.id] = storedValue ?? element.value;
} else if (type === 'threshold') {
element.type = 'threshold';
const _val = element.value.split('|');
@@ -330,12 +334,11 @@ export function showResetPrefModal(api, pgAdmin, preferencesStore, onReset) {
preferencesStore.cache(); // Refresh preferences cache
onReset();
if (reloadNow) {
- reloadPgAdmin();
+ await reloadPgAdmin();
} else {
- pgAdmin.Browser.tree.destroy().then(() => {
- pgAdmin.Browser.Events.trigger('pgadmin-browser:tree:destroyed', undefined, undefined);
- modalClose(); // Close modal after tree destruction if no full reload
- });
+ await pgAdmin.Browser.tree.destroy();
+ pgAdmin.Browser.Events.trigger('pgadmin-browser:tree:destroyed', undefined, undefined);
+ modalClose(); // Close modal after tree destruction if no full reload
}
} catch (err) {
pgAdmin.Browser.notifier.alert(err.response?.data || err.message || gettext('Failed to reset preferences.'));
diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewer.jsx b/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewer.jsx
index 6476aaa47a4..642446c3380 100644
--- a/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewer.jsx
+++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewer.jsx
@@ -11,7 +11,7 @@ import { styled } from '@mui/material/styles';
import ReactDOMServer from 'react-dom/server';
import _ from 'lodash';
import { MapContainer, TileLayer, LayersControl, GeoJSON, useMap } from 'react-leaflet';
-import Leaflet, { CRS } from 'leaflet';
+import Leaflet from 'leaflet';
import {Geometry as WkxGeometry} from 'wkx';
import {Buffer} from 'buffer';
import gettext from 'sources/gettext';
@@ -21,6 +21,8 @@ import { Box } from '@mui/material';
import EmptyPanelMessage from '../../../../../../static/js/components/EmptyPanelMessage';
import { PANELS } from '../QueryToolConstants';
import { QueryToolContext } from '../QueryToolComponent';
+import usePreferences from '../../../../../../preferences/static/js/store';
+import { getCustomTileProvider, getMapCrs, getBaseLayers } from './GeometryViewerUtils';
const StyledBox = styled(Box)(({theme}) => ({
position: 'relative',
@@ -286,7 +288,7 @@ GeoJsonLayer.propTypes = {
setHomeCoordinates: PropTypes.func,
};
-function TheMap({data}) {
+function TheMap({data, customTileProvider}) {
const mapObj = useMap();
const resetLayersKey = useRef(0);
const zoomControlWithHome = useRef(null);
@@ -364,62 +366,16 @@ function TheMap({data}) {
)}
{data.selectedSRID === 4326 &&
-
-
-
-
-
-
-
- OpenStreetMap,'
- + ' © SRTM,'
- + ' © OpenTopoMap'
- }
- />
-
-
- OpenStreetMap,'
- + ' © CartoDB'
- }
- subdomains='abcd'
- />
-
-
- OpenStreetMap,'
- + ' © CartoDB'
- }
- subdomains='abcd'
- />
-
-
- OpenStreetMap,'
- + ' © CartoDB'
- }
- subdomains='abcd'
- />
-
+ {getBaseLayers(customTileProvider).map((layer)=>(
+
+
+
+ ))}
}
>
@@ -432,6 +388,7 @@ TheMap.propTypes = {
getPopupContent: PropTypes.func,
infoList: PropTypes.array,
}),
+ customTileProvider: PropTypes.object,
};
@@ -440,6 +397,14 @@ export function GeometryViewer({rows, columns, column}) {
const mapRef = React.useRef();
const contentRef = React.useRef();
const queryToolCtx = React.useContext(QueryToolContext);
+ // Subscribed to the preferences store so a preference change while the
+ // viewer tab is open takes effect immediately (the props are frozen at
+ // tab-open time, hence not passed from the parent).
+ const prefStore = usePreferences();
+
+ const customTileProvider = useMemo(() => {
+ return getCustomTileProvider(prefStore.getPreferencesForModule('sqleditor'));
+ }, [prefStore.version]);
const currentColumnKey = useMemo(() => column?.key, [column]);
@@ -455,8 +420,12 @@ export function GeometryViewer({rows, columns, column}) {
: [gettext('No spatial data found. At least one geometry or geography column is required for visualization.')],
};
}
- return parseData(rows, columns, column);
- }, [rows, columns, column, currentColumnKey]);
+ const parsed = parseData(rows, columns, column);
+ if (customTileProvider?.invalid) {
+ parsed.infoList.push(gettext('Custom tile provider URL is invalid and was ignored. It must start with http(s):// and contain the {x}, {y} and {z} placeholders.'));
+ }
+ return parsed;
+ }, [rows, columns, column, currentColumnKey, customTileProvider]);
useEffect(()=>{
let timeoutId;
@@ -477,18 +446,23 @@ export function GeometryViewer({rows, columns, column}) {
};
}, [queryToolCtx]);
- // Dynamic CRS is not supported. Use srid and column key as key and recreate the map on change
+ // Dynamic CRS and TileLayer options are not supported. Use srid, column
+ // key and the custom tile provider config as key and recreate the map on
+ // change.
+ const providerKey = customTileProvider && !customTileProvider.invalid
+ ? `${customTileProvider.crs}|${customTileProvider.url}|${customTileProvider.name}|${customTileProvider.maxZoom}|${customTileProvider.attribution}`
+ : 'default';
return (
-
+
-
+
);
diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewerUtils.js b/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewerUtils.js
new file mode 100644
index 00000000000..bf84e7ec846
--- /dev/null
+++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewerUtils.js
@@ -0,0 +1,120 @@
+/////////////////////////////////////////////////////////////
+//
+// pgAdmin 4 - PostgreSQL Tools
+//
+// Copyright (C) 2013 - 2026, The pgAdmin Development Team
+// This software is released under the PostgreSQL Licence
+//
+//////////////////////////////////////////////////////////////
+import { CRS } from 'leaflet';
+import DOMPurify from 'dompurify';
+import gettext from 'sources/gettext';
+
+const DEFAULT_MAX_ZOOM = 18;
+const OSM_ATTRIBUTION = '© OpenStreetMap';
+const CARTODB_ATTRIBUTION = OSM_ATTRIBUTION
+ + ', © CartoDB';
+
+// Builds the custom tile provider config from the sqleditor module
+// preferences, or null when the URL preference is blank (feature disabled).
+// A non-blank but unusable URL yields {invalid: true} so the caller can
+// surface a message and fall back to the default layers.
+export function getCustomTileProvider(prefs) {
+ const url = (prefs?.custom_tile_url ?? '').trim();
+ if (!url) {
+ return null;
+ }
+ if (!/^https?:\/\//.test(url) || !url.includes('{x}')
+ || !url.includes('{y}') || !url.includes('{z}')) {
+ return { invalid: true };
+ }
+ const crs = prefs.custom_tile_crs || 'EPSG:3857';
+ return {
+ url: url,
+ name: DOMPurify.sanitize((prefs.custom_tile_name ?? '').trim() || gettext('Custom')),
+ crs: crs,
+ attribution: DOMPurify.sanitize(prefs.custom_tile_attribution || ''),
+ maxZoom: prefs.custom_tile_max_zoom ?? DEFAULT_MAX_ZOOM,
+ isDefaultCrs: crs === 'EPSG:3857',
+ };
+}
+
+// Returns the Leaflet CRS the map must be created with. Tiles (and thus the
+// custom provider) only apply to SRID 4326 data; anything else renders on a
+// blank Cartesian plane as before. The provider CRS string is resolved
+// dynamically against Leaflet's CRS namespace ('EPSG:4326' -> CRS.EPSG4326),
+// falling back to Web Mercator for unknown codes.
+export function getMapCrs(selectedSRID, provider) {
+ if (selectedSRID !== 4326) {
+ return CRS.Simple;
+ }
+ if (!provider || provider.invalid) {
+ return CRS.EPSG3857;
+ }
+ const crs = CRS[provider.crs.replace(':', '')];
+ return crs?.code ? crs : CRS.EPSG3857;
+}
+
+// Returns the base layer configs for the LayersControl. Built-in layers are
+// all Web Mercator tiled, so they are only offered when no custom provider
+// is set or the custom provider is Web Mercator too; a custom provider in
+// any other CRS can only be combined with the empty layer.
+export function getBaseLayers(provider) {
+ const hasProvider = Boolean(provider) && !provider.invalid;
+ const layers = [{
+ name: gettext('Empty'),
+ url: '',
+ checked: false,
+ }];
+
+ if (!hasProvider || provider.isDefaultCrs) {
+ layers.push({
+ name: gettext('Street'),
+ url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ maxZoom: 19,
+ attribution: OSM_ATTRIBUTION,
+ checked: !hasProvider,
+ }, {
+ name: gettext('Topography'),
+ url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
+ maxZoom: 17,
+ attribution: OSM_ATTRIBUTION
+ + ', © SRTM'
+ + ', © OpenTopoMap',
+ checked: false,
+ }, {
+ name: gettext('Gray Style'),
+ url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}{r}.png',
+ maxZoom: 19,
+ attribution: CARTODB_ATTRIBUTION,
+ subdomains: 'abcd',
+ checked: false,
+ }, {
+ name: gettext('Light Color'),
+ url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/rastertiles/voyager/{z}/{x}/{y}{r}.png',
+ maxZoom: 19,
+ attribution: CARTODB_ATTRIBUTION,
+ subdomains: 'abcd',
+ checked: false,
+ }, {
+ name: gettext('Dark Matter'),
+ url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}{r}.png',
+ maxZoom: 19,
+ attribution: CARTODB_ATTRIBUTION,
+ subdomains: 'abcd',
+ checked: false,
+ });
+ }
+
+ if (hasProvider) {
+ layers.push({
+ name: provider.name,
+ url: provider.url,
+ maxZoom: provider.maxZoom,
+ attribution: provider.attribution,
+ checked: true,
+ });
+ }
+
+ return layers;
+}
diff --git a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py
index a18edb13843..0f828999bb2 100644
--- a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py
+++ b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py
@@ -9,10 +9,10 @@
"""Register preferences for query tool"""
from flask_babel import gettext
-from pgadmin.utils.constants import PREF_LABEL_DISPLAY,\
- PREF_LABEL_KEYBOARD_SHORTCUTS, PREF_LABEL_EXPLAIN, PREF_LABEL_OPTIONS,\
- PREF_LABEL_CSV_TXT, PREF_LABEL_RESULTS_GRID,\
- PREF_LABEL_GRAPH_VISUALISER
+from pgadmin.utils.constants import PREF_LABEL_DISPLAY, \
+ PREF_LABEL_KEYBOARD_SHORTCUTS, PREF_LABEL_EXPLAIN, PREF_LABEL_OPTIONS, \
+ PREF_LABEL_CSV_TXT, PREF_LABEL_RESULTS_GRID, \
+ PREF_LABEL_GRAPH_VISUALISER, PREF_LABEL_GEOMETRY_VIEWER
from pgadmin.utils import SHORTCUT_FIELDS as shortcut_fields
from config import DATA_RESULT_ROWS_PER_PAGE
@@ -823,3 +823,65 @@ def register_query_tool_preferences(self):
'limit may impact performance if charts are plotted '
'with very high numbers of rows.')
)
+
+ self.custom_tile_url = self.preference.register(
+ 'geometry_viewer', 'custom_tile_url',
+ gettext("Custom tile provider URL"), 'text', '',
+ category_label=PREF_LABEL_GEOMETRY_VIEWER,
+ control_props={
+ 'placeholder': 'https://{s}.tile.example.com/{z}/{x}/{y}.png',
+ 'maxLength': 1024
+ },
+ help_str=gettext('URL template of a custom XYZ tile provider used '
+ 'as a base layer in the Geometry Viewer, e.g. '
+ 'https://myserver.example.com/tiles/{z}/{x}/{y}'
+ '.png. The template must contain the {x}, {y} and '
+ '{z} placeholders, and may contain {s} for '
+ 'subdomains (a, b, c). Leave empty to disable the '
+ 'custom tile provider.'),
+ allow_blanks=True
+ )
+
+ self.custom_tile_name = self.preference.register(
+ 'geometry_viewer', 'custom_tile_name',
+ gettext("Custom tile provider name"), 'text', 'Custom',
+ category_label=PREF_LABEL_GEOMETRY_VIEWER,
+ help_str=gettext('Display name of the custom tile provider in the '
+ 'layer selector of the Geometry Viewer.'),
+ allow_blanks=True
+ )
+
+ self.custom_tile_crs = self.preference.register(
+ 'geometry_viewer', 'custom_tile_crs',
+ gettext("Custom tile provider CRS"), 'options', 'EPSG:3857',
+ category_label=PREF_LABEL_GEOMETRY_VIEWER,
+ options=[{'label': gettext('EPSG:3857 (Web Mercator)'),
+ 'value': 'EPSG:3857'},
+ {'label': gettext('EPSG:4326'), 'value': 'EPSG:4326'},
+ {'label': gettext('EPSG:3395'), 'value': 'EPSG:3395'}],
+ control_props={
+ 'allowClear': False,
+ 'tags': False
+ },
+ help_str=gettext('Coordinate reference system of the custom tile '
+ 'provider. If it is not EPSG:3857 (Web Mercator), '
+ 'the built-in tile layers will be hidden as they '
+ 'cannot be mixed with other coordinate systems.')
+ )
+
+ self.custom_tile_attribution = self.preference.register(
+ 'geometry_viewer', 'custom_tile_attribution',
+ gettext("Custom tile provider attribution"), 'text', '',
+ category_label=PREF_LABEL_GEOMETRY_VIEWER,
+ help_str=gettext('Attribution text shown on the map for the custom '
+ 'tile provider. May contain HTML links.'),
+ allow_blanks=True
+ )
+
+ self.custom_tile_max_zoom = self.preference.register(
+ 'geometry_viewer', 'custom_tile_max_zoom',
+ gettext("Custom tile provider max zoom"), 'integer', 18,
+ min_val=0, max_val=25,
+ category_label=PREF_LABEL_GEOMETRY_VIEWER,
+ help_str=gettext('Maximum zoom level of the custom tile provider.')
+ )
diff --git a/web/pgadmin/utils/constants.py b/web/pgadmin/utils/constants.py
index d0567b39c2f..6b86c10480a 100644
--- a/web/pgadmin/utils/constants.py
+++ b/web/pgadmin/utils/constants.py
@@ -30,6 +30,7 @@
PREF_LABEL_TABS_SETTINGS = gettext('Tab settings')
PREF_LABEL_REFRESH_RATES = gettext('Refresh rates')
PREF_LABEL_GRAPH_VISUALISER = gettext('Graph Visualiser')
+PREF_LABEL_GEOMETRY_VIEWER = gettext('Geometry Viewer')
PREF_LABEL_USER_INTERFACE = gettext('User Interface')
PREF_LABEL_FILE_DOWNLOADS = gettext('File Downloads')
PREF_LABEL_AI = gettext('AI')
diff --git a/web/regression/javascript/sqleditor/GeometryViewerUtils.spec.js b/web/regression/javascript/sqleditor/GeometryViewerUtils.spec.js
new file mode 100644
index 00000000000..9868d8790e6
--- /dev/null
+++ b/web/regression/javascript/sqleditor/GeometryViewerUtils.spec.js
@@ -0,0 +1,141 @@
+/////////////////////////////////////////////////////////////
+//
+// pgAdmin 4 - PostgreSQL Tools
+//
+// Copyright (C) 2013 - 2026, The pgAdmin Development Team
+// This software is released under the PostgreSQL Licence
+//
+//////////////////////////////////////////////////////////////
+
+import { CRS } from 'leaflet';
+import {
+ getCustomTileProvider, getMapCrs, getBaseLayers,
+} from '../../../pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewerUtils';
+
+const VALID_URL = 'https://tiles.example.com/{z}/{x}/{y}.png';
+
+describe('GeometryViewerUtils', ()=>{
+
+ describe('getCustomTileProvider', ()=>{
+ it('returns null when no preferences or blank URL', ()=>{
+ expect(getCustomTileProvider(undefined)).toBeNull();
+ expect(getCustomTileProvider({})).toBeNull();
+ expect(getCustomTileProvider({custom_tile_url: ''})).toBeNull();
+ expect(getCustomTileProvider({custom_tile_url: ' '})).toBeNull();
+ });
+
+ it('flags URLs that are not usable tile templates as invalid', ()=>{
+ expect(getCustomTileProvider({custom_tile_url: 'ftp://x/{z}/{x}/{y}'}))
+ .toEqual({invalid: true});
+ expect(getCustomTileProvider({custom_tile_url: 'tiles/{z}/{x}/{y}'}))
+ .toEqual({invalid: true});
+ expect(getCustomTileProvider({custom_tile_url: 'https://x.com/{x}/{y}'}))
+ .toEqual({invalid: true});
+ expect(getCustomTileProvider({custom_tile_url: 'https://x.com/{z}/{x}'}))
+ .toEqual({invalid: true});
+ });
+
+ it('applies defaults for missing optional preferences', ()=>{
+ const provider = getCustomTileProvider({custom_tile_url: VALID_URL});
+ expect(provider).toEqual({
+ url: VALID_URL,
+ name: 'Custom',
+ crs: 'EPSG:3857',
+ attribution: '',
+ maxZoom: 18,
+ isDefaultCrs: true,
+ });
+ });
+
+ it('uses the configured values when present', ()=>{
+ const provider = getCustomTileProvider({
+ custom_tile_url: VALID_URL,
+ custom_tile_name: 'My Tiles',
+ custom_tile_crs: 'EPSG:4326',
+ custom_tile_attribution: '© Example',
+ custom_tile_max_zoom: 12,
+ });
+ expect(provider.name).toBe('My Tiles');
+ expect(provider.crs).toBe('EPSG:4326');
+ expect(provider.maxZoom).toBe(12);
+ expect(provider.isDefaultCrs).toBe(false);
+ expect(provider.attribution).toContain('Example');
+ });
+
+ it('sanitizes script content out of the attribution', ()=>{
+ const provider = getCustomTileProvider({
+ custom_tile_url: VALID_URL,
+ custom_tile_attribution: 'safe',
+ });
+ expect(provider.attribution).toBe('safe');
+ });
+
+ it('sanitizes script content out of the custom tile name', ()=>{
+ const provider = getCustomTileProvider({
+ custom_tile_url: VALID_URL,
+ custom_tile_name: 'safe',
+ });
+ expect(provider.name).toBe('safe');
+ });
+ });
+
+ describe('getMapCrs', ()=>{
+ it('always uses CRS.Simple for non-4326 data', ()=>{
+ expect(getMapCrs(0, null)).toBe(CRS.Simple);
+ expect(getMapCrs(3857, getCustomTileProvider({custom_tile_url: VALID_URL})))
+ .toBe(CRS.Simple);
+ });
+
+ it('defaults to Web Mercator without a usable provider', ()=>{
+ expect(getMapCrs(4326, null)).toBe(CRS.EPSG3857);
+ expect(getMapCrs(4326, {invalid: true})).toBe(CRS.EPSG3857);
+ });
+
+ it('resolves the provider CRS dynamically from leaflet', ()=>{
+ expect(getMapCrs(4326, {crs: 'EPSG:3857'})).toBe(CRS.EPSG3857);
+ expect(getMapCrs(4326, {crs: 'EPSG:4326'})).toBe(CRS.EPSG4326);
+ expect(getMapCrs(4326, {crs: 'EPSG:3395'})).toBe(CRS.EPSG3395);
+ });
+
+ it('falls back to Web Mercator for unknown CRS codes', ()=>{
+ expect(getMapCrs(4326, {crs: 'EPSG:9999'})).toBe(CRS.EPSG3857);
+ // CRS.Simple exists in leaflet but has no code - not a tile CRS
+ expect(getMapCrs(4326, {crs: 'Simple'})).toBe(CRS.EPSG3857);
+ });
+ });
+
+ describe('getBaseLayers', ()=>{
+ it('returns the built-in layers with Street checked when no provider', ()=>{
+ const layers = getBaseLayers(null);
+ expect(layers.map((l)=>l.name)).toEqual([
+ 'Empty', 'Street', 'Topography', 'Gray Style', 'Light Color',
+ 'Dark Matter',
+ ]);
+ expect(layers.filter((l)=>l.checked).map((l)=>l.name)).toEqual(['Street']);
+ });
+
+ it('treats an invalid provider like no provider', ()=>{
+ expect(getBaseLayers({invalid: true})).toEqual(getBaseLayers(null));
+ });
+
+ it('appends a Web Mercator provider to the built-in layers, checked', ()=>{
+ const layers = getBaseLayers(getCustomTileProvider({
+ custom_tile_url: VALID_URL,
+ custom_tile_name: 'My Tiles',
+ }));
+ expect(layers.length).toBe(7);
+ expect(layers[6].name).toBe('My Tiles');
+ expect(layers[6].url).toBe(VALID_URL);
+ expect(layers.filter((l)=>l.checked).map((l)=>l.name)).toEqual(['My Tiles']);
+ });
+
+ it('offers only Empty and the provider for non-mercator CRS', ()=>{
+ const layers = getBaseLayers(getCustomTileProvider({
+ custom_tile_url: VALID_URL,
+ custom_tile_crs: 'EPSG:4326',
+ }));
+ expect(layers.map((l)=>l.name)).toEqual(['Empty', 'Custom']);
+ expect(layers.filter((l)=>l.checked).map((l)=>l.name)).toEqual(['Custom']);
+ });
+ });
+});