Add a geometry viewer that can render PostGIS data on a blank canvas or various map sources. Fixes #1407
parent
b665f8fac8
commit
89e283fbc2
|
@ -95,6 +95,28 @@ To delete a row, press the *Delete* toolbar button. A popup will open, asking y
|
|||
|
||||
To commit the changes to the server, select the *Save* toolbar button. Modifications to a row are written to the server automatically when you select a different row.
|
||||
|
||||
**Geometry Data Viewer**
|
||||
|
||||
If PostGIS is installed, you can view GIS objects in a map by selecting row(s) and clicking the 'View Geometry' button in the column. If no rows are selected, the entire data set will be rendered:
|
||||
|
||||
.. image:: images/geometry_viewer.png
|
||||
:alt: Geometry Viewer Button
|
||||
|
||||
You can adjust the layout by dragging the title of the panel. To view the properties of the geometries directly in map, just click the specific geometry:
|
||||
|
||||
.. image:: images/geometry_viewer_property_table.png
|
||||
:alt: Geometry Viewer Property Table
|
||||
|
||||
Notes:
|
||||
|
||||
- *Supported data types:* The Geometry Viewer supports 2D and 3DM geometries in EWKB format including `Point, LineString, Polygon MultiPoint, MultiLineString, MultiPolygon and GeometryCollection`.
|
||||
|
||||
- *SRIDs:* If there are geometries with different SRIDs in the same column, the viewer will render geometries with the same SRID in the map. If SRID=4326 the OSM tile layer will be added into the map.
|
||||
|
||||
- *Data size:* For performance reasons, the viewer will render no more than 100000 geometries, totaling up to 20MB.
|
||||
|
||||
- *Internet access:* An internet connection is required for the Geometry Viewer to function correctly.
|
||||
|
||||
**Sort/Filter options dialog**
|
||||
|
||||
You can access *Sort/Filter options dialog* by clicking on Sort/Filter button. This allows you to specify an SQL Filter to limit the data displayed and data sorting options in the edit grid window:
|
||||
|
@ -120,4 +142,3 @@ To delete a row from the grid, click the trash icon.
|
|||
* Click the *Help* button (?) to access online help.
|
||||
* Click the *Ok* button to save work.
|
||||
* Click the *Close* button to discard current changes and close the dialog.
|
||||
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 449 KiB |
Binary file not shown.
After Width: | Height: | Size: 495 KiB |
|
@ -10,6 +10,7 @@ This release contains a number of features and fixes reported since the release
|
|||
Features
|
||||
********
|
||||
|
||||
| `Feature #1407 <https://redmine.postgresql.org/issues/1407>`_ - Add a geometry viewer that can render PostGIS data on a blank canvas or various map sources.
|
||||
| `Feature #3503 <https://redmine.postgresql.org/issues/3503>`_ - Added new backup/restore options for PostgreSQL 11. Added dump options for 'pg_dumpall'.
|
||||
| `Feature #3553 <https://redmine.postgresql.org/issues/3553>`_ - Add a Spanish translation.
|
||||
|
||||
|
|
|
@ -39,3 +39,5 @@ Open Sans 2.0 AL https://fonts.googleapis.com/css
|
|||
Spectrum 1.8 MIT https://bgrins.github.io/spectrum/
|
||||
Mousetrap 1.6.1 AL https://github.com/ccampbell/mousetrap
|
||||
Axios 0.18.0 MIT https://github.com/axios/axios
|
||||
leaflet 1.3.3 BSD https://leafletjs.com/
|
||||
wkx 0.4.5 MIT https://github.com/cschwarz/wkx
|
||||
|
|
|
@ -74,6 +74,7 @@
|
|||
"jquery": "3.3.1",
|
||||
"jquery-contextmenu": "^2.6.4",
|
||||
"jquery-ui": "^1.12.1",
|
||||
"leaflet": "^1.3.3",
|
||||
"moment": "^2.20.1",
|
||||
"mousetrap": "^1.6.1",
|
||||
"prop-types": "^15.5.10",
|
||||
|
@ -91,7 +92,8 @@
|
|||
"underscore": "^1.8.3",
|
||||
"underscore.string": "^3.3.4",
|
||||
"watchify": "~3.9.0",
|
||||
"webcabin-docker": "git+https://github.com/EnterpriseDB/wcDocker"
|
||||
"webcabin-docker": "git+https://github.com/EnterpriseDB/wcDocker",
|
||||
"wkx": "^0.4.5"
|
||||
},
|
||||
"scripts": {
|
||||
"linter": "yarn eslint --no-eslintrc -c .eslintrc.js --ext .js --ext .jsx .",
|
||||
|
|
|
@ -8,5 +8,7 @@ import 'slickgrid/slick.formatters';
|
|||
import 'slickgrid/plugins/slick.autotooltips';
|
||||
import 'slickgrid/plugins/slick.cellrangedecorator';
|
||||
import 'slickgrid/plugins/slick.cellrangeselector';
|
||||
import 'sources/slickgrid/custom_header_buttons';
|
||||
|
||||
export default window.Slick;
|
||||
|
||||
export default window.Slick;
|
|
@ -14,6 +14,7 @@
|
|||
@import '~webcabin-docker/Build/wcDocker.css';
|
||||
@import '~acitree/css/aciTree.css';
|
||||
@import '~spectrum-colorpicker/spectrum.css';
|
||||
@import '~leaflet/dist/leaflet.css';
|
||||
|
||||
@import '~codemirror/lib/codemirror.css';
|
||||
@import '~codemirror/addon/dialog/dialog.css';
|
||||
|
|
|
@ -0,0 +1,180 @@
|
|||
(function ($) {
|
||||
// register namespace
|
||||
$.extend(true, window, {
|
||||
'Slick': {
|
||||
'Plugins': {
|
||||
'HeaderButtons': HeaderButtons,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
/***
|
||||
* custom header button modified from slick.headerbuttons.js
|
||||
*
|
||||
* USAGE:
|
||||
*
|
||||
* Add the plugin .js & .css files and register it with the grid.
|
||||
*
|
||||
* To specify a custom button in a column header, extend the column definition like so:
|
||||
*
|
||||
* var columns = [
|
||||
* {
|
||||
* id: 'myColumn',
|
||||
* name: 'My column',
|
||||
*
|
||||
* // This is the relevant part
|
||||
* header: {
|
||||
* buttons: [
|
||||
* {
|
||||
* // button options
|
||||
* },
|
||||
* {
|
||||
* // button options
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
* ];
|
||||
*
|
||||
* Available button options:
|
||||
* cssClass: CSS class to add to the button.
|
||||
* image: Relative button image path.
|
||||
* tooltip: Button tooltip.
|
||||
* showOnHover: Only show the button on hover.
|
||||
* handler: Button click handler.
|
||||
* command: A command identifier to be passed to the onCommand event handlers.
|
||||
*
|
||||
* The plugin exposes the following events:
|
||||
* onCommand: Fired on button click for buttons with 'command' specified.
|
||||
* Event args:
|
||||
* grid: Reference to the grid.
|
||||
* column: Column definition.
|
||||
* command: Button command identified.
|
||||
* button: Button options. Note that you can change the button options in your
|
||||
* event handler, and the column header will be automatically updated to
|
||||
* reflect them. This is useful if you want to implement something like a
|
||||
* toggle button.
|
||||
*
|
||||
*
|
||||
* @param options {Object} Options:
|
||||
* buttonCssClass: a CSS class to use for buttons (default 'slick-header-button')
|
||||
* @class Slick.Plugins.HeaderButtons
|
||||
* @constructor
|
||||
*/
|
||||
function HeaderButtons(options) {
|
||||
var _grid;
|
||||
var _self = this;
|
||||
var _handler = new window.Slick.EventHandler();
|
||||
var _defaults = {
|
||||
buttonCssClass: 'slick-header-button',
|
||||
};
|
||||
|
||||
|
||||
function init(grid) {
|
||||
options = $.extend(true, {}, _defaults, options);
|
||||
_grid = grid;
|
||||
_handler
|
||||
.subscribe(_grid.onHeaderCellRendered, handleHeaderCellRendered)
|
||||
.subscribe(_grid.onBeforeHeaderCellDestroy, handleBeforeHeaderCellDestroy);
|
||||
|
||||
// Force the grid to re-render the header now that the events are hooked up.
|
||||
_grid.setColumns(_grid.getColumns());
|
||||
}
|
||||
|
||||
|
||||
function destroy() {
|
||||
_handler.unsubscribeAll();
|
||||
}
|
||||
|
||||
|
||||
function handleHeaderCellRendered(e, args) {
|
||||
var column = args.column;
|
||||
|
||||
if (column.header && column.header.buttons) {
|
||||
// Append buttons in reverse order since they are floated to the right.
|
||||
var i = column.header.buttons.length;
|
||||
while (i--) {
|
||||
var button = column.header.buttons[i];
|
||||
var btn = $('<div></div>')
|
||||
.addClass(options.buttonCssClass)
|
||||
.data('column', column)
|
||||
.data('button', button);
|
||||
|
||||
if (button.content){
|
||||
btn.append(button.content);
|
||||
}
|
||||
|
||||
if (button.showOnHover) {
|
||||
btn.addClass('slick-header-button-hidden');
|
||||
}
|
||||
|
||||
if (button.image) {
|
||||
btn.css('backgroundImage', 'url(' + button.image + ')');
|
||||
}
|
||||
|
||||
if (button.cssClass) {
|
||||
btn.addClass(button.cssClass);
|
||||
}
|
||||
|
||||
if (button.tooltip) {
|
||||
btn.attr('title', button.tooltip);
|
||||
}
|
||||
|
||||
if (button.command) {
|
||||
btn.data('command', button.command);
|
||||
}
|
||||
|
||||
if (button.handler) {
|
||||
btn.bind('click', button.handler);
|
||||
}
|
||||
|
||||
btn
|
||||
.bind('click', handleButtonClick)
|
||||
.prependTo(args.node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleBeforeHeaderCellDestroy(e, args) {
|
||||
var column = args.column;
|
||||
|
||||
if (column.header && column.header.buttons) {
|
||||
// Removing buttons via jQuery will also clean up any event handlers and data.
|
||||
// NOTE: If you attach event handlers directly or using a different framework,
|
||||
// you must also clean them up here to avoid memory leaks.
|
||||
$(args.node).find('.' + options.buttonCssClass).remove();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleButtonClick(e) {
|
||||
var command = $(this).data('command');
|
||||
var columnDef = $(this).data('column');
|
||||
var button = $(this).data('button');
|
||||
|
||||
if (command != null) {
|
||||
_self.onCommand.notify({
|
||||
'grid': _grid,
|
||||
'column': columnDef,
|
||||
'command': command,
|
||||
'button': button,
|
||||
}, e, _self);
|
||||
|
||||
// Update the header in case the user updated the button definition in the handler.
|
||||
_grid.updateColumnHeader(columnDef.id);
|
||||
}
|
||||
|
||||
// Stop propagation so that it doesn't register as a header click event.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
$.extend(this, {
|
||||
'init': init,
|
||||
'destroy': destroy,
|
||||
'onCommand': new window.Slick.Event(),
|
||||
});
|
||||
}
|
||||
})(window.jQuery);
|
|
@ -0,0 +1,417 @@
|
|||
/////////////////////////////////////////////////////////////
|
||||
//
|
||||
// pgAdmin 4 - PostgreSQL Tools
|
||||
//
|
||||
// Copyright (C) 2013 - 2018, The pgAdmin Development Team
|
||||
// This software is released under the PostgreSQL Licence
|
||||
//
|
||||
//////////////////////////////////////////////////////////////
|
||||
|
||||
import gettext from 'sources/gettext';
|
||||
import {Geometry} from 'wkx';
|
||||
import {Buffer} from 'buffer';
|
||||
import L from 'leaflet';
|
||||
import $ from 'jquery';
|
||||
|
||||
let GeometryViewer = {
|
||||
panel_closed: true,
|
||||
|
||||
render_geometries: function (handler, items, columns, columnIndex) {
|
||||
let self = this;
|
||||
if (!self.map_component) {
|
||||
self.map_component = initMapComponent();
|
||||
}
|
||||
|
||||
if (self.panel_closed) {
|
||||
let wcDocker = window.wcDocker;
|
||||
let geometry_viewer_panel = handler.gridView.geometry_viewer =
|
||||
handler.gridView.docker.addPanel('geometry_viewer',
|
||||
wcDocker.DOCK.STACKED, handler.gridView.data_output_panel);
|
||||
$('#geometry_viewer_panel')[0].appendChild(self.map_component.mapContainer.get(0));
|
||||
self.panel_closed = false;
|
||||
|
||||
geometry_viewer_panel.on(wcDocker.EVENT.CLOSED, function () {
|
||||
$('#geometry_viewer_panel').empty();
|
||||
self.map_component.clearMap();
|
||||
self.panel_closed = true;
|
||||
});
|
||||
|
||||
geometry_viewer_panel.on(wcDocker.EVENT.RESIZE_ENDED, function () {
|
||||
if (geometry_viewer_panel.isVisible()) {
|
||||
self.map_component.resizeMap();
|
||||
}
|
||||
});
|
||||
|
||||
geometry_viewer_panel.on(wcDocker.EVENT.VISIBILITY_CHANGED, function (visible) {
|
||||
if (visible) {
|
||||
self.map_component.resizeMap();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handler.gridView.geometry_viewer.focus();
|
||||
self.map_component.clearMap();
|
||||
let dataObj = parseData(items, columns, columnIndex);
|
||||
self.map_component.renderMap(dataObj);
|
||||
},
|
||||
|
||||
add_header_button: function (columnDefinition) {
|
||||
columnDefinition.header = {
|
||||
buttons: [
|
||||
{
|
||||
cssClass: 'div-view-geometry-column',
|
||||
tooltip: 'View all geometries in this column',
|
||||
showOnHover: false,
|
||||
command: 'view-geometries',
|
||||
content: '<button class="btn-xs btn-primary"><i class="fa fa-eye" aria-hidden="true"></i></button>',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
parse_data: parseData,
|
||||
};
|
||||
|
||||
function initMapComponent() {
|
||||
const geojsonMarkerOptions = {
|
||||
radius: 4,
|
||||
weight: 3,
|
||||
};
|
||||
const geojsonStyle = {
|
||||
weight: 2,
|
||||
};
|
||||
const popupOption = {
|
||||
closeButton: false,
|
||||
minWidth: 260,
|
||||
maxWidth: 300,
|
||||
maxHeight: 300,
|
||||
};
|
||||
|
||||
let mapContainer = $('<div class="geometry-viewer-container"></div>');
|
||||
let lmap = L.map(mapContainer.get(0), {
|
||||
preferCanvas: true,
|
||||
}).setZoom(0);
|
||||
|
||||
// update default attribution
|
||||
lmap.attributionControl.setPrefix('');
|
||||
|
||||
let vectorLayer = L.geoJSON([], {
|
||||
style: geojsonStyle,
|
||||
pointToLayer: function (feature, latlng) {
|
||||
return L.circleMarker(latlng, geojsonMarkerOptions);
|
||||
},
|
||||
});
|
||||
vectorLayer.addTo(lmap);
|
||||
let baseLayersObj = {
|
||||
'Empty': L.tileLayer(''),
|
||||
'Street': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
{
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="http://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>',
|
||||
}),
|
||||
'Topography': L.tileLayer('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>',
|
||||
}),
|
||||
'Gray Style': L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}{r}.png',
|
||||
{
|
||||
attribution: '© <a href="http://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>,' +
|
||||
' © <a href="http://cartodb.com/attributions" target="_blank">CartoDB</a>',
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19,
|
||||
}),
|
||||
'Light Color': L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||
{
|
||||
attribution: '© <a href="http://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>,' +
|
||||
' © <a href="http://cartodb.com/attributions" target="_blank">CartoDB</a>',
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19,
|
||||
}),
|
||||
'Dark Matter': L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}{r}.png',
|
||||
{
|
||||
attribution: '© <a href="http://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>,' +
|
||||
' © <a href="http://cartodb.com/attributions" target="_blank">CartoDB</a>',
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19,
|
||||
}),
|
||||
};
|
||||
let layerControl = L.control.layers(baseLayersObj);
|
||||
let defaultBaseLayer = baseLayersObj.Street;
|
||||
let baseLayers = _.values(baseLayersObj);
|
||||
|
||||
let infoControl = L.control({position: 'topright'});
|
||||
infoControl.onAdd = function () {
|
||||
this._div = L.DomUtil.create('div', 'geometry-viewer-info-control');
|
||||
return this._div;
|
||||
};
|
||||
infoControl.update = function (content) {
|
||||
this._div.innerHTML = content;
|
||||
};
|
||||
|
||||
let setEPSG3857 = function () {
|
||||
if (lmap.options.crs !== L.CRS.EPSG3857) {
|
||||
lmap.options.crs = L.CRS.EPSG3857;
|
||||
layerControl.addTo(lmap);
|
||||
lmap.addLayer(defaultBaseLayer);
|
||||
mapContainer.addClass('geometry-viewer-container-plain-background');
|
||||
lmap.setMinZoom(0);
|
||||
}
|
||||
};
|
||||
|
||||
let setSimpleCRS = function () {
|
||||
if (lmap.options.crs !== L.CRS.Simple) {
|
||||
lmap.options.crs = L.CRS.Simple;
|
||||
layerControl.remove();
|
||||
_.each(baseLayers, function (layer) {
|
||||
if (lmap.hasLayer(layer)) {
|
||||
defaultBaseLayer = layer;
|
||||
layer.remove();
|
||||
}
|
||||
});
|
||||
mapContainer.removeClass('geometry-viewer-container-plain-background');
|
||||
}
|
||||
};
|
||||
|
||||
setSimpleCRS();
|
||||
setEPSG3857();
|
||||
|
||||
return {
|
||||
'mapContainer': mapContainer,
|
||||
'clearMap': function () {
|
||||
lmap.closePopup();
|
||||
infoControl.remove();
|
||||
vectorLayer.clearLayers();
|
||||
},
|
||||
|
||||
'renderMap': function (dataObj) {
|
||||
let geoJSONs = dataObj.geoJSONs,
|
||||
SRID = dataObj.selectedSRID,
|
||||
getPopupContent = dataObj.getPopupContent,
|
||||
infoList = dataObj.infoList;
|
||||
|
||||
let isEmpty = false;
|
||||
if (geoJSONs.length === 0) {
|
||||
isEmpty = true;
|
||||
}
|
||||
|
||||
try {
|
||||
vectorLayer.addData(geoJSONs);
|
||||
} catch (e) {
|
||||
// Invalid LatLng object: (NaN, NaN)
|
||||
infoList.push('An error occurred while rendering data.');
|
||||
isEmpty = true;
|
||||
}
|
||||
|
||||
let bounds = vectorLayer.getBounds();
|
||||
if (!bounds.isValid()) {
|
||||
isEmpty = true;
|
||||
}
|
||||
|
||||
if (infoList.length > 0) {
|
||||
if (lmap.options.crs === L.CRS.EPSG3857) {
|
||||
layerControl.remove();
|
||||
infoControl.addTo(lmap);
|
||||
layerControl.addTo(lmap);
|
||||
} else {
|
||||
infoControl.addTo(lmap);
|
||||
}
|
||||
let infoContent = generateInfoContent(infoList);
|
||||
infoControl.update(infoContent);
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
setSimpleCRS();
|
||||
lmap.setView([0, 0], lmap.getZoom());
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof getPopupContent === 'function') {
|
||||
let addPopup = function (layer) {
|
||||
layer.bindPopup(function () {
|
||||
return getPopupContent(layer.feature.geometry);
|
||||
}, popupOption);
|
||||
};
|
||||
vectorLayer.eachLayer(addPopup);
|
||||
}
|
||||
|
||||
bounds = bounds.pad(0.1);
|
||||
let maxLength = Math.max(bounds.getNorth() - bounds.getSouth(),
|
||||
bounds.getEast() - bounds.getWest());
|
||||
if (SRID === 4326) {
|
||||
setEPSG3857();
|
||||
} else {
|
||||
setSimpleCRS();
|
||||
if (maxLength >= 180) {
|
||||
// calculate the min zoom level to enable the map to fit the whole geometry.
|
||||
let minZoom = Math.floor(Math.log2(360 / maxLength)) - 2;
|
||||
lmap.setMinZoom(minZoom);
|
||||
} else {
|
||||
lmap.setMinZoom(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (maxLength > 0) {
|
||||
lmap.fitBounds(bounds);
|
||||
} else {
|
||||
lmap.setView(bounds.getCenter(), lmap.getZoom());
|
||||
}
|
||||
},
|
||||
|
||||
'resizeMap': function () {
|
||||
setTimeout(function () {
|
||||
lmap.invalidateSize();
|
||||
}, 10);
|
||||
},
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
function parseData(items, columns, columnIndex) {
|
||||
const maxRenderByteLength = 20 * 1024 * 1024; //render geometry data up to 20MB
|
||||
const maxRenderGeometries = 100000; // render geometries up to 100000
|
||||
let field = columns[columnIndex].field;
|
||||
let geometries3D = [],
|
||||
supportedGeometries = [],
|
||||
unsupportedItems = [],
|
||||
infoList = [],
|
||||
geometryItemMap = new Map(),
|
||||
mixedSRID = false,
|
||||
geometryTotalByteLength = 0,
|
||||
tooLargeDataSize = false,
|
||||
tooManyGeometries = false;
|
||||
|
||||
if (items.length === 0) {
|
||||
infoList.push('Empty row.');
|
||||
return {
|
||||
'geoJSONs': [],
|
||||
'selectedSRID': 0,
|
||||
'getPopupContent': undefined,
|
||||
'infoList': infoList,
|
||||
};
|
||||
}
|
||||
|
||||
// parse ewkb data
|
||||
_.every(items, function (item) {
|
||||
try {
|
||||
let value = item[field];
|
||||
let buffer = Buffer.from(value, 'hex');
|
||||
let geometry = Geometry.parse(buffer);
|
||||
if (geometry.hasZ) {
|
||||
geometries3D.push(geometry);
|
||||
} else {
|
||||
geometryTotalByteLength += buffer.byteLength;
|
||||
if (geometryTotalByteLength > maxRenderByteLength) {
|
||||
tooLargeDataSize = true;
|
||||
return false;
|
||||
}
|
||||
if (supportedGeometries.length >= maxRenderGeometries) {
|
||||
tooManyGeometries = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!geometry.srid) {
|
||||
geometry.srid = 0;
|
||||
}
|
||||
supportedGeometries.push(geometry);
|
||||
geometryItemMap.set(geometry, item);
|
||||
}
|
||||
} catch (e) {
|
||||
unsupportedItems.push(item);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// generate map info content
|
||||
if (tooLargeDataSize || tooManyGeometries) {
|
||||
infoList.push(supportedGeometries.length + ' of ' + items.length + ' geometries rendered.');
|
||||
}
|
||||
if (geometries3D.length > 0) {
|
||||
infoList.push(gettext('3D geometries not rendered.'));
|
||||
}
|
||||
if (unsupportedItems.length > 0) {
|
||||
infoList.push(gettext('Unsupported geometries not rendered.'));
|
||||
}
|
||||
|
||||
if (supportedGeometries.length === 0) {
|
||||
return {
|
||||
'geoJSONs': [],
|
||||
'selectedSRID': 0,
|
||||
'getPopupContent': undefined,
|
||||
'infoList': infoList,
|
||||
};
|
||||
}
|
||||
|
||||
// group geometries by SRID
|
||||
let geometriesGroupBySRID = _.groupBy(supportedGeometries, 'srid');
|
||||
let SRIDGeometriesPairs = _.pairs(geometriesGroupBySRID);
|
||||
if (SRIDGeometriesPairs.length > 1) {
|
||||
mixedSRID = true;
|
||||
}
|
||||
// select the largest group
|
||||
let selectedPair = _.max(SRIDGeometriesPairs, function (pair) {
|
||||
return pair[1].length;
|
||||
});
|
||||
let selectedSRID = parseInt(selectedPair[0]);
|
||||
let selectedGeometries = selectedPair[1];
|
||||
|
||||
let geoJSONs = _.map(selectedGeometries, function (geometry) {
|
||||
return geometry.toGeoJSON();
|
||||
});
|
||||
|
||||
let getPopupContent;
|
||||
if (columns.length >= 3) {
|
||||
// add popup when geometry has properties
|
||||
getPopupContent = function (geojson) {
|
||||
let geometry = selectedGeometries[geoJSONs.indexOf(geojson)];
|
||||
let item = geometryItemMap.get(geometry);
|
||||
return itemToTable(item, columns, columnIndex);
|
||||
};
|
||||
}
|
||||
|
||||
if (mixedSRID) {
|
||||
infoList.push(gettext('Geometries with non-SRID') + selectedSRID + ' not rendered.');
|
||||
}
|
||||
|
||||
return {
|
||||
'geoJSONs': geoJSONs,
|
||||
'selectedSRID': selectedSRID,
|
||||
'getPopupContent': getPopupContent,
|
||||
'infoList': infoList,
|
||||
};
|
||||
}
|
||||
|
||||
function itemToTable(item, columns, ignoredColumnIndex) {
|
||||
let content = '<table class="table table-bordered table-striped view-geometry-property-table"><tbody>';
|
||||
|
||||
// start from 1 because columns[0] is empty
|
||||
for (let i = 1; i < columns.length; i++) {
|
||||
if (i !== ignoredColumnIndex) {
|
||||
let columnDef = columns[i];
|
||||
content += '<tr><th>' + columnDef.display_name + '</th>';
|
||||
|
||||
let value = item[columnDef.field];
|
||||
if (_.isUndefined(value) && columnDef.has_default_val) {
|
||||
content += '<td class="td-disabled">[default]</td>';
|
||||
} else if ((_.isUndefined(value) && columnDef.not_null) ||
|
||||
(_.isUndefined(value) || value === null)) {
|
||||
content += '<td class="td-disabled">[null]</td>';
|
||||
} else {
|
||||
content += '<td>' + value + '</td>';
|
||||
}
|
||||
|
||||
content += '</tr>';
|
||||
}
|
||||
}
|
||||
content += '</tbody></table>';
|
||||
return content;
|
||||
}
|
||||
|
||||
function generateInfoContent(infoList) {
|
||||
let infoContent = infoList.join('<br>');
|
||||
return infoContent;
|
||||
}
|
||||
|
||||
module.exports = GeometryViewer;
|
|
@ -641,3 +641,76 @@ input.editor-checkbox:focus {
|
|||
.connection-status-hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* For geometry data viewer panel */
|
||||
.sql-editor-geometry-viewer{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.geometry-viewer-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAANElEQVQoU2O8e/fuf2VlZUYGLAAkB5bApggmBteJrAiZjWI0SAJkIrKVxCvAawVeRxLyJgB+Ajc1cwux9wAAAABJRU5ErkJggg==);
|
||||
}
|
||||
|
||||
/* For leaflet map background */
|
||||
.geometry-viewer-container-plain-background {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* For geometry column button */
|
||||
.div-view-geometry-column {
|
||||
float: right;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
display: -webkit-flex;
|
||||
align-items: center;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* For leaflet popup */
|
||||
.leaflet-popup-content-wrapper {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.leaflet-popup-content {
|
||||
margin: 5px;
|
||||
padding: 10px 10px 0;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* For geometry viewer property table */
|
||||
.view-geometry-property-table {
|
||||
table-layout: fixed;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.view-geometry-property-table th {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.view-geometry-property-table td {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.view-geometry-property-table .td-disabled{
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
/* For geometry viewer info control */
|
||||
.geometry-viewer-info-control {
|
||||
padding: 5px;
|
||||
background: white;
|
||||
border: 2px solid rgba(0, 0, 0, 0.2);
|
||||
background-clip: padding-box;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.geometry-viewer-info-control i{
|
||||
margin: 0 0 0 4px;
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ define('tools.querytool', [
|
|||
'sources/sqleditor/execute_query',
|
||||
'sources/sqleditor/query_tool_http_error_handler',
|
||||
'sources/sqleditor/filter_dialog',
|
||||
'sources/sqleditor/geometry_viewer',
|
||||
'sources/history/index.js',
|
||||
'sourcesjsx/history/query_history',
|
||||
'react', 'react-dom',
|
||||
|
@ -37,7 +38,7 @@ define('tools.querytool', [
|
|||
babelPollyfill, gettext, url_for, $, _, S, alertify, pgAdmin, Backbone, codemirror,
|
||||
pgExplain, GridSelector, ActiveCellCapture, clipboard, copyData, RangeSelectionHelper, handleQueryOutputKeyboardEvent,
|
||||
XCellSelectionModel, setStagedRows, SqlEditorUtils, ExecuteQuery, httpErrorHandler, FilterHandler,
|
||||
HistoryBundle, queryHistory, React, ReactDOM,
|
||||
GeometryViewer, HistoryBundle, queryHistory, React, ReactDOM,
|
||||
keyboardShortcuts, queryToolActions, queryToolNotifications, Datagrid,
|
||||
modifyAnimation, calculateQueryRunTime, callRenderAfterPoll, queryToolPref) {
|
||||
/* Return back, this has been called more than once */
|
||||
|
@ -217,7 +218,7 @@ define('tools.querytool', [
|
|||
}, 200);
|
||||
});
|
||||
|
||||
// Create panels for 'Data Output', 'Explain', 'Messages' and 'History'
|
||||
// Create panels for 'Data Output', 'Explain', 'Messages', 'History' and 'Geometry Viewer'
|
||||
var data_output = new pgAdmin.Browser.Panel({
|
||||
name: 'data_output',
|
||||
title: gettext('Data Output'),
|
||||
|
@ -268,12 +269,23 @@ define('tools.querytool', [
|
|||
content: '<div id ="notification_grid" class="sql-editor-notifications" tabindex: "0"></div>',
|
||||
});
|
||||
|
||||
var geometry_viewer = new pgAdmin.Browser.Panel({
|
||||
name: 'geometry_viewer',
|
||||
title: gettext('Geometry Viewer'),
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
isCloseable: true,
|
||||
isPrivate: true,
|
||||
content: '<div id ="geometry_viewer_panel" class="sql-editor-geometry-viewer" tabindex: "0"></div>',
|
||||
});
|
||||
|
||||
// Load all the created panels
|
||||
data_output.load(main_docker);
|
||||
explain.load(main_docker);
|
||||
messages.load(main_docker);
|
||||
history.load(main_docker);
|
||||
notifications.load(main_docker);
|
||||
geometry_viewer.load(main_docker);
|
||||
|
||||
// Add all the panels to the docker
|
||||
self.data_output_panel = main_docker.addPanel('data_output', wcDocker.DOCK.BOTTOM, sql_panel_obj);
|
||||
|
@ -605,6 +617,8 @@ define('tools.querytool', [
|
|||
- This plugin is useful for selecting rows using checkbox
|
||||
3) RowSelectionModel
|
||||
- This plugin is needed by CheckboxSelectColumn plugin to select rows
|
||||
4) Slick.HeaderButtons
|
||||
- This plugin is useful for add buttons in column header
|
||||
|
||||
Grid Options:
|
||||
-------------
|
||||
|
@ -743,6 +757,9 @@ define('tools.querytool', [
|
|||
} else if (c.cell == 'binary') {
|
||||
// We do not support editing binary data in SQL editor and data grid.
|
||||
options['formatter'] = Slick.Formatters.Binary;
|
||||
} else if (c.cell == 'geometry' || c.cell == 'geography') {
|
||||
// increase width to add 'view' button
|
||||
options['width'] += 28;
|
||||
} else {
|
||||
options['editor'] = is_editable ? Slick.Editors.pgText :
|
||||
Slick.Editors.ReadOnlypgText;
|
||||
|
@ -755,6 +772,13 @@ define('tools.querytool', [
|
|||
var gridSelector = new GridSelector();
|
||||
grid_columns = self.grid_columns = gridSelector.getColumnDefinitions(grid_columns);
|
||||
|
||||
// add 'view' button in geometry and geography type column header
|
||||
_.each(grid_columns, function (c) {
|
||||
if (c.column_type_internal == 'geometry' || c.column_type_internal == 'geography') {
|
||||
GeometryViewer.add_header_button(c);
|
||||
}
|
||||
});
|
||||
|
||||
if (rows_affected) {
|
||||
// calculate with for header row column.
|
||||
grid_columns[0]['width'] = SqlEditorUtils.calculateColumnWidth(rows_affected);
|
||||
|
@ -817,6 +841,38 @@ define('tools.querytool', [
|
|||
grid.registerPlugin(new ActiveCellCapture());
|
||||
grid.setSelectionModel(new XCellSelectionModel());
|
||||
grid.registerPlugin(gridSelector);
|
||||
var headerButtonsPlugin = new Slick.Plugins.HeaderButtons();
|
||||
headerButtonsPlugin.onCommand.subscribe(function (e, args) {
|
||||
let command = args.command;
|
||||
if (command === 'view-geometries') {
|
||||
let columns = args.grid.getColumns();
|
||||
let columnIndex = columns.indexOf(args.column);
|
||||
let selectedRows = args.grid.getSelectedRows();
|
||||
if (selectedRows.length === 0) {
|
||||
// if no rows are selected, load and render all the rows
|
||||
if (self.handler.has_more_rows) {
|
||||
self.fetch_next_all(function () {
|
||||
// trigger onGridSelectAll manually with new event data.
|
||||
gridSelector.onGridSelectAll.notify(args, new Slick.EventData());
|
||||
let items = args.grid.getData().getItems();
|
||||
GeometryViewer.render_geometries(self.handler, items, columns, columnIndex);
|
||||
});
|
||||
} else {
|
||||
gridSelector.onGridSelectAll.notify(args, new Slick.EventData());
|
||||
let items = args.grid.getData().getItems();
|
||||
GeometryViewer.render_geometries(self.handler, items, columns, columnIndex);
|
||||
}
|
||||
} else {
|
||||
// render selected rows
|
||||
let items = args.grid.getData().getItems();
|
||||
let selectedItems = _.map(selectedRows, function (row) {
|
||||
return items[row];
|
||||
});
|
||||
GeometryViewer.render_geometries(self.handler, selectedItems, columns, columnIndex);
|
||||
}
|
||||
}
|
||||
});
|
||||
grid.registerPlugin(headerButtonsPlugin);
|
||||
|
||||
var editor_data = {
|
||||
keys: (_.isEmpty(self.handler.primary_keys) && self.handler.has_oids) ? self.handler.oids : self.handler.primary_keys,
|
||||
|
@ -2398,6 +2454,14 @@ define('tools.querytool', [
|
|||
case 'bytea[]':
|
||||
col_cell = 'binary';
|
||||
break;
|
||||
case 'geometry':
|
||||
// PostGIS geometry type
|
||||
col_cell = 'geometry';
|
||||
break;
|
||||
case 'geography':
|
||||
// PostGIS geography type
|
||||
col_cell = 'geography';
|
||||
break;
|
||||
default:
|
||||
col_cell = 'string';
|
||||
}
|
||||
|
|
|
@ -0,0 +1,231 @@
|
|||
/////////////////////////////////////////////////////////////
|
||||
//
|
||||
// pgAdmin 4 - PostgreSQL Tools
|
||||
//
|
||||
// Copyright (C) 2013 - 2018, The pgAdmin Development Team
|
||||
// This software is released under the PostgreSQL Licence
|
||||
//
|
||||
//////////////////////////////////////////////////////////////
|
||||
|
||||
import GeometryViewer from 'sources/sqleditor/geometry_viewer';
|
||||
|
||||
describe('geometry viewer test', function () {
|
||||
|
||||
describe('geometry viewer add header button test', function () {
|
||||
let add_button = GeometryViewer.add_header_button;
|
||||
it('should add button for geography type', function () {
|
||||
let columnDef = {
|
||||
column_type_internal: 'geography',
|
||||
};
|
||||
add_button(columnDef);
|
||||
expect(columnDef.header).toBeDefined();
|
||||
});
|
||||
|
||||
it('should add button for geometry type', function () {
|
||||
let columnDef = {
|
||||
column_type_internal: 'geometry',
|
||||
};
|
||||
add_button(columnDef);
|
||||
expect(columnDef.header).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('geometry viewer rener geometry test', function () {
|
||||
|
||||
it('should group geometry by srid', function () {
|
||||
// POINT(0 0)
|
||||
let ewkb = '010100000000000000000000000000000000000000';
|
||||
// SRID=32632;POINT(0 0)
|
||||
let ewkb1 = '0101000020787F000000000000000000000000000000000000';
|
||||
let items = [{
|
||||
id: 1,
|
||||
geom: ewkb,
|
||||
}, {
|
||||
id: 1,
|
||||
geom: ewkb,
|
||||
}, {
|
||||
id: 1,
|
||||
geom: ewkb1,
|
||||
}];
|
||||
let columns = [
|
||||
{
|
||||
column_type_internal: 'geometry',
|
||||
field: 'geom',
|
||||
},
|
||||
];
|
||||
let columnIndex = 0;
|
||||
let result = GeometryViewer.parse_data(items, columns, columnIndex);
|
||||
expect(result.geoJSONs.length).toBe(2);
|
||||
});
|
||||
|
||||
|
||||
it('should support geometry collection', function () {
|
||||
// GEOMETRYCOLLECTION(POINT(2 3),LINESTRING(2 3,3 4))
|
||||
let ewkb = '01070000000200000001010000000000000000000040000000000000084001' +
|
||||
'02000000020000000000000000000040000000000000084000000000000008400000000' +
|
||||
'000001040';
|
||||
let items = [{
|
||||
id: 1,
|
||||
geom: ewkb,
|
||||
}];
|
||||
let columns = [
|
||||
{
|
||||
column_type_internal: 'geometry',
|
||||
field: 'geom',
|
||||
},
|
||||
];
|
||||
let columnIndex = 0;
|
||||
let result = GeometryViewer.parse_data(items, columns, columnIndex);
|
||||
expect(result.geoJSONs.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should support geometry M', function () {
|
||||
// SRID=4326;MULTIPOINTM(0 0 0,1 2 1)
|
||||
let ewkb = '0104000060E610000002000000010100004000000000000000000000000000' +
|
||||
'00000000000000000000000101000040000000000000F03F00000000000000400000000' +
|
||||
'00000F03F';
|
||||
let items = [{
|
||||
id: 1,
|
||||
geom: ewkb,
|
||||
}];
|
||||
let columns = [
|
||||
{
|
||||
column_type_internal: 'geometry',
|
||||
field: 'geom',
|
||||
},
|
||||
];
|
||||
let columnIndex = 0;
|
||||
let result = GeometryViewer.parse_data(items, columns, columnIndex);
|
||||
expect(result.geoJSONs.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should support empty geometry', function () {
|
||||
// GEOMETRYCOLLECTION EMPTY
|
||||
let ewkb = '010700000000000000';
|
||||
let items = [{
|
||||
id: 1,
|
||||
geom: ewkb,
|
||||
}];
|
||||
let columns = [
|
||||
{
|
||||
column_type_internal: 'geometry',
|
||||
field: 'geom',
|
||||
},
|
||||
];
|
||||
let columnIndex = 0;
|
||||
let result = GeometryViewer.parse_data(items, columns, columnIndex);
|
||||
expect(result.geoJSONs.length).toBe(1);
|
||||
});
|
||||
|
||||
|
||||
it('should support mixed geometry type', function () {
|
||||
// GEOMETRYCOLLECTION EMPTY
|
||||
let ewkb = '010700000000000000';
|
||||
// POINT(0 0)
|
||||
let ewkb1 = '010100000000000000000000000000000000000000';
|
||||
// SRID=4326;MULTIPOINTM(0 0 0,1 2 1)
|
||||
let ewkb2 = '0104000060E610000002000000010100004000000000000000000000000000' +
|
||||
'00000000000000000000000101000040000000000000F03F00000000000000400000000' +
|
||||
'00000F03F';
|
||||
let items = [{
|
||||
id: 1,
|
||||
geom: ewkb,
|
||||
}, {
|
||||
id: 1,
|
||||
geom: ewkb1,
|
||||
}, {
|
||||
id: 1,
|
||||
geom: ewkb2,
|
||||
}];
|
||||
let columns = [
|
||||
{
|
||||
column_type_internal: 'geometry',
|
||||
field: 'geom',
|
||||
},
|
||||
];
|
||||
let columnIndex = 0;
|
||||
let result = GeometryViewer.parse_data(items, columns, columnIndex);
|
||||
expect(result.geoJSONs.length).toBe(2);
|
||||
});
|
||||
|
||||
|
||||
it('should not support 3D geometry', function () {
|
||||
// POINT(0 0 0)
|
||||
let ewkb = '0101000080000000000000F03F000000000000F03F000000000000F03F';
|
||||
let items = [{
|
||||
id: 1,
|
||||
geom: ewkb,
|
||||
}];
|
||||
let columns = [
|
||||
{
|
||||
column_type_internal: 'geometry',
|
||||
field: 'geom',
|
||||
},
|
||||
];
|
||||
let columnIndex = 0;
|
||||
let result = GeometryViewer.parse_data(items, columns, columnIndex);
|
||||
expect(result.geoJSONs.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should not support 3DM geometry', function () {
|
||||
// POINT(0 0 0 0)
|
||||
let ewkb = '01010000C00000000000000000000000000000000000000000000000000000' +
|
||||
'000000000000';
|
||||
let items = [{
|
||||
id: 1,
|
||||
geom: ewkb,
|
||||
}];
|
||||
let columns = [
|
||||
{
|
||||
column_type_internal: 'geometry',
|
||||
field: 'geom',
|
||||
},
|
||||
];
|
||||
let columnIndex = 0;
|
||||
let result = GeometryViewer.parse_data(items, columns, columnIndex);
|
||||
expect(result.geoJSONs.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should not support TRIANGLE geometry', function () {
|
||||
// TRIANGLE ((0 0, 0 9, 9 0, 0 0))
|
||||
let ewkb = '01110000000100000004000000000000000000000000000000000000000000' +
|
||||
'00000000000000000000000022400000000000002240000000000000000000000000000' +
|
||||
'000000000000000000000';
|
||||
let items = [{
|
||||
id: 1,
|
||||
geom: ewkb,
|
||||
}];
|
||||
let columns = [
|
||||
{
|
||||
column_type_internal: 'geometry',
|
||||
field: 'geom',
|
||||
},
|
||||
];
|
||||
let columnIndex = 0;
|
||||
let result = GeometryViewer.parse_data(items, columns, columnIndex);
|
||||
expect(result.geoJSONs.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should limit data size', function () {
|
||||
// POINT(0 0)
|
||||
let ewkb = '010100000000000000000000000000000000000000';
|
||||
let items = [];
|
||||
for (let i = 0; i < 600000; i++) {
|
||||
items.push({
|
||||
id: i,
|
||||
geom: ewkb,
|
||||
});
|
||||
}
|
||||
|
||||
let columns = [
|
||||
{
|
||||
column_type_internal: 'geometry',
|
||||
field: 'geom',
|
||||
},
|
||||
];
|
||||
let columnIndex = 0;
|
||||
let result = GeometryViewer.parse_data(items, columns, columnIndex);
|
||||
expect(result.geoJSONs.length).toBeLessThan(600000);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5604,6 +5604,10 @@ lead@^1.0.0:
|
|||
dependencies:
|
||||
flush-write-stream "^1.0.2"
|
||||
|
||||
leaflet@^1.3.3:
|
||||
version "1.3.3"
|
||||
resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.3.3.tgz#5c8f2fd50e4a41ead93ab850dcd9e058811da9b9"
|
||||
|
||||
level-codec@~7.0.0:
|
||||
version "7.0.1"
|
||||
resolved "https://registry.yarnpkg.com/level-codec/-/level-codec-7.0.1.tgz#341f22f907ce0f16763f24bddd681e395a0fb8a7"
|
||||
|
@ -9611,6 +9615,12 @@ window-size@0.1.0:
|
|||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d"
|
||||
|
||||
wkx@^0.4.5:
|
||||
version "0.4.5"
|
||||
resolved "https://registry.yarnpkg.com/wkx/-/wkx-0.4.5.tgz#a85e15a6e69d1bfaec2f3c523be3dfa40ab861d0"
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
wordwrap@0.0.2:
|
||||
version "0.0.2"
|
||||
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f"
|
||||
|
|
Loading…
Reference in New Issue