Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useMemo } from 'react';
import { styled } from '@mui/material/styles';
import ReactDOMServer from 'react-dom/server';
import _ from 'lodash';
Expand All @@ -18,10 +18,12 @@ import gettext from 'sources/gettext';
import Theme from 'sources/Theme';
import PropTypes from 'prop-types';
import { Box } from '@mui/material';
import EmptyPanelMessage from '../../../../../../static/js/components/EmptyPanelMessage';
import { PANELS } from '../QueryToolConstants';
import { QueryToolContext } from '../QueryToolComponent';

const StyledBox = styled(Box)(({theme}) => ({
position: 'relative',
'& .GeometryViewer-mapContainer': {
backgroundColor: theme.palette.background.default,
height: '100%',
Expand Down Expand Up @@ -191,6 +193,7 @@ function parseData(rows, columns, column) {
};
}


function PopupTable({data}) {

return (
Expand Down Expand Up @@ -285,20 +288,10 @@ GeoJsonLayer.propTypes = {

function TheMap({data}) {
const mapObj = useMap();
const infoControl = useRef(null);
const resetLayersKey = useRef(0);
const zoomControlWithHome = useRef(null);
const homeCoordinates = useRef(null);
useEffect(()=>{
infoControl.current = Leaflet.control({position: 'topright'});
infoControl.current.onAdd = function () {
let ele = Leaflet.DomUtil.create('div', 'geometry-viewer-info-control');
ele.innerHTML = data.infoList.join('<br />');
return ele;
};
if(data.infoList.length > 0) {
infoControl.current.addTo(mapObj);
}
resetLayersKey.current++;

zoomControlWithHome.current = Leaflet.control.zoom({
Expand Down Expand Up @@ -348,7 +341,6 @@ function TheMap({data}) {
zoomControlWithHome.current.addTo(mapObj);

return ()=>{
infoControl.current?.remove();
zoomControlWithHome.current?.remove();
};
}, [data]);
Expand All @@ -359,6 +351,17 @@ function TheMap({data}) {

return (
<>
{data.infoList.length > 0 && (
<EmptyPanelMessage text={data.infoList.join(' ')} style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 1000,
pointerEvents: 'none',
}} />
)}
{data.selectedSRID === 4326 &&
<LayersControl position="topright">
<LayersControl.BaseLayer checked name={gettext('Empty')}>
Expand Down Expand Up @@ -436,25 +439,47 @@ export function GeometryViewer({rows, columns, column}) {

const mapRef = React.useRef();
const contentRef = React.useRef();
const data = parseData(rows, columns, column);
const queryToolCtx = React.useContext(QueryToolContext);

const currentColumnKey = useMemo(() => column?.key, [column]);

const data = React.useMemo(() => {
if (!currentColumnKey) {
const hasGeometryColumn = columns.some(c => c.cell === 'geometry' || c.cell === 'geography');
return {
'geoJSONs': [],
'selectedSRID': 0,
'getPopupContent': undefined,
'infoList': hasGeometryColumn
? [gettext('Query complete. Use the Geometry Viewer button in the Data Output tab to visualize results.')]
: [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]);

useEffect(()=>{
let timeoutId;
const contentResizeObserver = new ResizeObserver(()=>{
clearTimeout(timeoutId);
if(queryToolCtx.docker.isTabVisible(PANELS.GEOMETRY)) {
if(queryToolCtx?.docker?.isTabVisible(PANELS.GEOMETRY)) {
timeoutId = setTimeout(function () {
mapRef.current?.invalidateSize();
}, 100);
}
});
contentResizeObserver.observe(contentRef.current);
}, []);
if(contentRef.current) {
contentResizeObserver.observe(contentRef.current);
}
return () => {
clearTimeout(timeoutId);
contentResizeObserver.disconnect();
};
}, [queryToolCtx]);

// Dyanmic CRS is not supported. Use srid as key and recreate the map on change
// Dynamic CRS is not supported. Use srid and column key as key and recreate the map on change
return (
<StyledBox ref={contentRef} width="100%" height="100%" key={data.selectedSRID}>
<StyledBox ref={contentRef} width="100%" height="100%" key={`${data.selectedSRID}-${currentColumnKey || 'none'}`}>
<MapContainer
crs={data.selectedSRID === 4326 ? CRS.EPSG3857 : CRS.Simple}
zoom={2} center={[20, 100]}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -876,6 +876,16 @@ export function ResultSet() {
rsu.current.setLoaderText = setLoaderText;

const isDataChangedRef = useRef(false);
const lastGvSelectionRef = useRef({
type: 'all', // 'all' | 'rows' | 'columns' | 'range' | 'cell'
geometryColumnKey: null,
rowIndices: [],
columnIndices: new Set(),
rangeStartIdx: null,
rangeEndIdx: null,
cellIdx: null,
});

useEffect(()=>{
isDataChangedRef.current = Boolean(_.size(dataChangeStore.updated) || _.size(dataChangeStore.added) || _.size(dataChangeStore.deleted));
}, [dataChangeStore]);
Expand Down Expand Up @@ -1460,30 +1470,103 @@ export function ResultSet() {
return ()=>eventBus.deregisterListener(QUERY_TOOL_EVENTS.TRIGGER_ADD_ROWS, triggerAddRows);
}, [columns, selectedRows.size]);

const openGeometryViewerTab = React.useCallback((column, rowsData) => {
layoutDocker.openTab({
id: PANELS.GEOMETRY,
title: gettext('Geometry Viewer'),
content: <GeometryViewer rows={rowsData} columns={columns} column={column}/>,
closable: true,
}, PANELS.MESSAGES, 'after-tab', true);
}, [layoutDocker, columns]);

// Handle manual Geometry Viewer opening.
// Determines which rows to plot based on the current grid selection (rows, columns,
// range, or cell) and stores the selection indices in lastGvSelectionRef so the
// auto-update effect can re-apply the same selection on subsequent query re-runs.
useEffect(()=>{
const renderGeometries = (column)=>{
const defaultSel = { geometryColumnKey: column?.key, rowIndices: [], columnIndices: new Set(), rangeStartIdx: null, rangeEndIdx: null, cellIdx: null };
let selRowsData = rows;
if(selectedRows.size != 0) {
selRowsData = rows.filter((r)=>selectedRows.has(rowKeyGetter(r)));

if(selectedRows.size > 0) {
// Specific rows selected in the grid — plot only those rows
const rowIndices = [];
rows.forEach((r, i) => {
if(selectedRows.has(rowKeyGetter(r))) {
rowIndices.push(i);
}
});
selRowsData = rowIndices.map(i => rows[i]);
lastGvSelectionRef.current = { ...defaultSel, type: 'rows', rowIndices };
} else if(selectedColumns.size > 0) {
let selectedCols = _.filter(columns, (_c, i)=>selectedColumns.has(i+1));
selRowsData = _.map(rows, (r)=>_.pick(r, _.map(selectedCols, (c)=>c.key)));
// Specific columns selected — plot all rows but only with selected column data
let selectedCols = _.filter(columns, (_c, i) => selectedColumns.has(i + 1));
selRowsData = _.map(rows, (r) => _.pick(r, _.map(selectedCols, (c) => c.key)));
lastGvSelectionRef.current = { ...defaultSel, type: 'columns', columnIndices: new Set(selectedColumns) };
} else if(selectedRange.current) {
// Cell range selected — plot the rows within the range
let [,, startRowIdx, endRowIdx] = getRangeIndexes();
selRowsData = rows.slice(startRowIdx, endRowIdx+1);
selRowsData = rows.slice(startRowIdx, endRowIdx + 1);
lastGvSelectionRef.current = { ...defaultSel, type: 'range', rangeStartIdx: startRowIdx, rangeEndIdx: endRowIdx };
} else if(selectedCell.current?.[0]) {
// Single cell selected — plot only that row
const cellIdx = rows.indexOf(selectedCell.current[0]);
selRowsData = [selectedCell.current[0]];
lastGvSelectionRef.current = { ...defaultSel, type: 'cell', cellIdx: cellIdx >= 0 ? cellIdx : null };
} else {
// No selection — plot all rows
lastGvSelectionRef.current = { ...defaultSel, type: 'all' };
}
layoutDocker.openTab({
id: PANELS.GEOMETRY,
title:gettext('Geometry Viewer'),
content: <GeometryViewer rows={selRowsData} columns={columns} column={column} />,
closable: true,
}, PANELS.MESSAGES, 'after-tab', true);

openGeometryViewerTab(column, selRowsData);
};
eventBus.registerListener(QUERY_TOOL_EVENTS.TRIGGER_RENDER_GEOMETRIES, renderGeometries);
return ()=>eventBus.deregisterListener(QUERY_TOOL_EVENTS.TRIGGER_RENDER_GEOMETRIES, renderGeometries);
}, [rows, columns, selectedRows.size, selectedColumns.size]);
}, [openGeometryViewerTab, eventBus, rows, columns, selectedRows, selectedColumns]);

// Auto-update Geometry Viewer when rows/columns change
useEffect(()=>{
if(layoutDocker.isTabOpen(PANELS.GEOMETRY)) {
const lastGeomKey = lastGvSelectionRef.current.geometryColumnKey;
const matchedGeomCol = lastGeomKey
? columns.find(c => c.key === lastGeomKey && (c.cell === 'geometry' || c.cell === 'geography'))
: null;

if(matchedGeomCol) {
// Previously plotted geometry column still exists → re-apply selection and re-render
const lastSel = lastGvSelectionRef.current;
let selRowsData = rows;

// Re-apply row selection — plot only previously selected rows if indices are still valid
if(lastSel.type === 'rows' && lastSel.rowIndices.length > 0) {
if(lastSel.rowIndices.every(idx => idx < rows.length)) {
selRowsData = lastSel.rowIndices.map(idx => rows[idx]);
}
// Re-apply column selection — filter each row to only the previously selected columns
} else if(lastSel.type === 'columns' && lastSel.columnIndices.size > 0) {
let selectedCols = _.filter(columns, (_c, i) => lastSel.columnIndices.has(i + 1));
if(selectedCols.length > 0) {
selRowsData = _.map(rows, (r) => _.pick(r, _.map(selectedCols, (c) => c.key)));
}
// Re-apply range selection — plot the previously selected row range if bounds are still valid
} else if(lastSel.type === 'range' && lastSel.rangeStartIdx != null) {
if(lastSel.rangeStartIdx < rows.length && lastSel.rangeEndIdx < rows.length) {
selRowsData = rows.slice(lastSel.rangeStartIdx, lastSel.rangeEndIdx + 1);
}
// Re-apply single cell selection — plot the row of the previously selected cell if still valid
} else if(lastSel.type === 'cell' && lastSel.cellIdx != null) {
if(lastSel.cellIdx < rows.length) {
selRowsData = [rows[lastSel.cellIdx]];
}
}
// If any validation fails above, selRowsData remains as all rows (default)
openGeometryViewerTab(matchedGeomCol, selRowsData);
} else {
// Previously plotted geometry column not found - clear GV
openGeometryViewerTab(null, []);
}
}
}, [rows, columns, layoutDocker]);

const triggerResetScroll = () => {
// Reset the scroll position to previously saved location.
Expand Down
Loading