Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/en_US/editgrid.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
27 changes: 27 additions & 0 deletions docs/en_US/preferences.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 11 additions & 8 deletions web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand All @@ -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') {
Expand Down Expand Up @@ -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('|');
Expand Down Expand Up @@ -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.'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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',
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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} />
</>
Expand All @@ -432,6 +388,7 @@ TheMap.propTypes = {
getPopupContent: PropTypes.func,
infoList: PropTypes.array,
}),
customTileProvider: PropTypes.object,
};


Expand All @@ -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]);

Expand All @@ -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;
Expand All @@ -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>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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;
}
Loading