feat: add custom tile provider settings for Geometry Viewer (#10142)

Geometry Viewer's base-layer choices were hardcoded (Empty, Street,
Topography, Gray Style, Light Color/Dark Matter), with no way to
point at a private/internal tile server or a provider not in that
built-in list.

Add a geometry_viewer preferences category (custom_tile_url, _name,
_crs, _attribution, _max_zoom) under sqleditor. When a valid custom
tile provider URL is configured (must be http(s) and contain {x},
{y}, {z}), it's used as the default base layer; built-in tiled
layers are hidden unless the custom provider's CRS is also
EPSG:3857 (Web Mercator), since they can't be reprojected together.
Non-mercator custom providers (e.g. EPSG:4326) only apply to SRID
4326 geometry data; other SRIDs still render on the existing blank
Cartesian plane.

Custom name/attribution are DOMPurify-sanitized before being passed
to Leaflet (both are innerHTML sinks). URL is not sanitized but is
gated by the http(s):// prefix check, so javascript:/data: URLs are
rejected; it's consumed as a tile-image src template, not an HTML
sink.

Adds GeometryViewerUtils.spec.js covering URL validation, defaults,
sanitization, CRS resolution/fallback, and base-layer composition.
This commit is contained in:
Niv Greenstein authored and GitHub committed 2026-07-24 22:17:51 +05:30
1 parent 7339eb8dcc
commit 108797d48a
8 files changed
+409 -76

No files matched your search

+5
View File
@@ -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.
+27
View File
@@ -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
@@ -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.'));
@@ -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 &&
<LayersControl position="topright">
<LayersControl.BaseLayer checked name={gettext('Empty')}>
<TileLayer
url=""
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer checked name={gettext('Street')}>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
maxZoom={19}
attribution='&copy; <a href="http://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>'
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name={gettext('Topography')}>
<TileLayer
url="https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png"
maxZoom={17}
attribution={
'&copy; <a href="http://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>,'
+ ' &copy; <a href="http://viewfinderpanoramas.org" target="_blank">SRTM</a>,'
+ ' &copy; <a href="https://opentopomap.org" target="_blank">OpenTopoMap</a>'
}
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name={gettext('Gray Style')}>
<TileLayer
url="https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}{r}.png"
maxZoom={19}
attribution={
'&copy; <a href="http://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>,'
+ ' &copy; <a href="http://cartodb.com/attributions" target="_blank">CartoDB</a>'
}
subdomains='abcd'
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name={gettext('Light Color')}>
<TileLayer
url="https://cartodb-basemaps-{s}.global.ssl.fastly.net/rastertiles/voyager/{z}/{x}/{y}{r}.pn"
maxZoom={19}
attribution={
'&copy; <a href="http://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>,'
+ ' &copy; <a href="http://cartodb.com/attributions" target="_blank">CartoDB</a>'
}
subdomains='abcd'
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name={gettext('Dark Matter')}>
<TileLayer
url="https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}{r}.png"
maxZoom={19}
attribution={
'&copy; <a href="http://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>,'
+ ' &copy; <a href="http://cartodb.com/attributions" target="_blank">CartoDB</a>'
}
subdomains='abcd'
/>
</LayersControl.BaseLayer>
{getBaseLayers(customTileProvider).map((layer)=>(
<LayersControl.BaseLayer key={`${layer.name}::${layer.url}`} name={layer.name} checked={layer.checked}>
<TileLayer
url={layer.url}
{...(!_.isUndefined(layer.maxZoom) ? {maxZoom: layer.maxZoom} : {})}
{...(layer.attribution ? {attribution: layer.attribution} : {})}
{...(layer.subdomains ? {subdomains: layer.subdomains} : {})}
/>
</LayersControl.BaseLayer>
))}
</LayersControl>}
<GeoJsonLayer key={resetLayersKey.current} data={data} setHomeCoordinates={setHomeCoordinates} />
</>
@@ -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 (
<StyledBox ref={contentRef} width="100%" height="100%" key={`${data.selectedSRID}-${currentColumnKey || 'none'}`}>
<StyledBox ref={contentRef} width="100%" height="100%" key={`${data.selectedSRID}-${currentColumnKey || 'none'}-${providerKey}`}>
<MapContainer
crs={data.selectedSRID === 4326 ? CRS.EPSG3857 : CRS.Simple}
crs={getMapCrs(data.selectedSRID, customTileProvider)}
zoom={2} center={[20, 100]}
zoomControl={false}
preferCanvas={true}
className='GeometryViewer-mapContainer'
ref={mapRef}
>
<TheMap data={data}/>
<TheMap data={data} customTileProvider={customTileProvider}/>
</MapContainer>
</StyledBox>
);
@@ -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 = '&copy; <a href="http://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>';
const CARTODB_ATTRIBUTION = OSM_ATTRIBUTION
+ ', &copy; <a href="http://cartodb.com/attributions" target="_blank">CartoDB</a>';
// 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
+ ', &copy; <a href="http://viewfinderpanoramas.org" target="_blank">SRTM</a>'
+ ', &copy; <a href="https://opentopomap.org" target="_blank">OpenTopoMap</a>',
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;
}
@@ -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
@@ -838,3 +838,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.')
)
+1
View File
@@ -30,6 +30,7 @@ PREF_LABEL_SQL_FORMATTING = gettext('SQL formatting')
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')
@@ -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: '&copy; <a href="https://example.com">Example</a>',
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<script>alert(1)</script>',
});
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<script>alert(1)</script>',
});
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']);
});
});
});