mirror of
https://github.com/pgadmin-org/pgadmin4.git
synced 2026-11-01 15:53:03 -06:00
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:
1 parent
7339eb8dcc
commit
108797d48a
8 files changed
+409
-76
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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='© <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={
|
||||
'© <a href="http://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>,'
|
||||
+ ' © <a href="http://viewfinderpanoramas.org" target="_blank">SRTM</a>,'
|
||||
+ ' © <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={
|
||||
'© <a href="http://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>,'
|
||||
+ ' © <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={
|
||||
'© <a href="http://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>,'
|
||||
+ ' © <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={
|
||||
'© <a href="http://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>,'
|
||||
+ ' © <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 = '© <a href="http://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>';
|
||||
const CARTODB_ATTRIBUTION = OSM_ATTRIBUTION
|
||||
+ ', © <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
|
||||
+ ', © <a href="http://viewfinderpanoramas.org" target="_blank">SRTM</a>'
|
||||
+ ', © <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.')
|
||||
)
|
||||
@@ -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: '© <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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user