diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewer.jsx b/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewer.jsx index 8842d6d287c..9f67d72455b 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewer.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewer.jsx @@ -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'; @@ -49,6 +49,8 @@ const StyledBox = styled(Box)(({theme}) => ({ }, })); +const PK_COLUMN_NAMES = ['id', 'oid']; + function parseEwkbData(rows, column) { let key = column.key; const maxRenderByteLength = 20 * 1024 * 1024; //render geometry data up to 20MB @@ -191,6 +193,33 @@ function parseData(rows, columns, column) { }; } +// Find primary key column from columns array +function findPkColumn(columns) { + return columns.find(c => PK_COLUMN_NAMES.includes(c.name)); +} + +// Get unique row identifier using PK column or first column +function getRowIdentifier(row, pkColumn, columns) { + if (pkColumn?.key && row[pkColumn.key] !== undefined) { + return row[pkColumn.key]; + } + const firstKey = columns[0]?.key; + return firstKey && row[firstKey] !== undefined ? row[firstKey] : JSON.stringify(row); +} + +// Create Set of row identifiers +function createIdentifierSet(rowData, pkColumn, columns) { + return new Set(rowData.map(row => getRowIdentifier(row, pkColumn, columns))); +} + +// Match rows from previous selection to current rows +function matchRowSelection(prevRowData, currentRows, pkColumn, columns) { + if (prevRowData.length === 0) return []; + + const prevIdSet = createIdentifierSet(prevRowData, pkColumn, columns); + return currentRows.filter(row => prevIdSet.has(getRowIdentifier(row, pkColumn, columns))); +} + function PopupTable({data}) { return ( @@ -294,6 +323,27 @@ function TheMap({data}) { infoControl.current.onAdd = function () { let ele = Leaflet.DomUtil.create('div', 'geometry-viewer-info-control'); ele.innerHTML = data.infoList.join('
'); + // Style the parent control container after it's added to the map + setTimeout(() => { + let controlContainer = ele.closest('.leaflet-control'); + if(controlContainer) { + controlContainer.style.cssText = ` + position: fixed; + top: 70%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; + max-width: 80%; + text-align: center; + white-space: normal; + word-wrap: break-word; + background: none; + box-shadow: none; + border: none; + font-size: 16px; + `; + } + }, 0); return ele; }; if(data.infoList.length > 0) { @@ -436,25 +486,111 @@ 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); + // Track previous column state AND selected row data + const prevStateRef = React.useRef({ + columnKey: null, + columnNames: null, + selectedRowData: [], + }); + + const [mapKey, setMapKey] = React.useState(0); + const currentColumnKey = useMemo(() => column?.key, [column]); + const currentColumnNames = React.useMemo( + () => columns.map(c => c.key).sort().join(','), + [columns] + ); + + const pkColumn = useMemo(() => findPkColumn(columns), [columns]); + + // Detect when to clear, filter, or re-render the map based on changes in geometry column, columns list, or rows + useEffect(() => { + const prevState = prevStateRef.current; + + if (!currentColumnKey) { + setMapKey(prev => prev + 1); + prevStateRef.current = { + columnKey: null, + columnNames: null, + selectedRowData: [], + }; + return; + } + + if (currentColumnKey !== prevState.columnKey || + currentColumnNames !== prevState.columnNames) { + setMapKey(prev => prev + 1); + prevStateRef.current = { + columnKey: currentColumnKey, + columnNames: currentColumnNames, + selectedRowData: rows, + }; + return; + } + + if (currentColumnKey === prevState.columnKey && + currentColumnNames === prevState.columnNames && + rows.length > 0) { + prevStateRef.current.selectedRowData = displayRows; + } + }, [currentColumnKey, currentColumnNames, rows, pkColumn, columns]); + + // Get rows to display based on selection + const displayRows = React.useMemo(() => { + if (!currentColumnKey || rows.length === 0) return []; + const prevState = prevStateRef.current; + if (currentColumnKey !== prevState.columnKey || currentColumnNames !== prevState.columnNames) { + return rows; + } + + const prevSelected = prevState.selectedRowData; + if (prevSelected.length === 0) return rows; + if (prevSelected.length < rows.length) { + const matched = matchRowSelection(prevSelected, rows, pkColumn, columns); + return matched.length > 0 ? matched : rows; + } + return rows; + }, [rows, currentColumnKey, currentColumnNames, pkColumn, columns]); + + // Parse geometry data only when needed + 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(displayRows, columns, column); + }, [displayRows, 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 mapKey as key and recreate the map on change return ( - + { isDataChangedRef.current = Boolean(_.size(dataChangeStore.updated) || _.size(dataChangeStore.added) || _.size(dataChangeStore.deleted)); }, [dataChangeStore]); @@ -1460,30 +1468,86 @@ export function ResultSet() { return ()=>eventBus.deregisterListener(QUERY_TOOL_EVENTS.TRIGGER_ADD_ROWS, triggerAddRows); }, [columns, selectedRows.size]); + const getFilteredRowsForGeometryViewer = React.useCallback((useLastGvSelection = false) => { + let selRowsData = rows; + if(selectedRows.size != 0) { + selRowsData = rows.filter((r)=>selectedRows.has(rowKeyGetter(r))); + } 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))); + } else if(useLastGvSelection && lastGvSelectionRef.current.type === 'columns' + && lastGvSelectionRef.current.selectedColumns.size > 0) { + let selectedCols = _.filter(columns, (_c, i)=>lastGvSelectionRef.current.selectedColumns.has(i+1)); + if(selectedCols.length > 0) { + selRowsData = _.map(rows, (r)=>_.pick(r, _.map(selectedCols, (c)=>c.key))); + } + } else if(selectedRange.current) { + let [,, startRowIdx, endRowIdx] = getRangeIndexes(); + selRowsData = rows.slice(startRowIdx, endRowIdx+1); + } else if(selectedCell.current?.[0]) { + selRowsData = [selectedCell.current[0]]; + } + return selRowsData; + }, [rows, columns, selectedRows, selectedColumns]); + + const openGeometryViewerTab = React.useCallback((column, rowsData) => { + layoutDocker.openTab({ + id: PANELS.GEOMETRY, + title: gettext('Geometry Viewer'), + content: , + closable: true, + }, PANELS.MESSAGES, 'after-tab', true); + }, [layoutDocker, columns]); + + // Handle manual Geometry Viewer opening useEffect(()=>{ const renderGeometries = (column)=>{ - let selRowsData = rows; - if(selectedRows.size != 0) { - selRowsData = rows.filter((r)=>selectedRows.has(rowKeyGetter(r))); + gvClearedForColumnsRef.current = null; + if(selectedRows.size > 0) { + lastGvSelectionRef.current = { type: 'rows', selectedColumns: new Set() }; } 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))); - } else if(selectedRange.current) { - let [,, startRowIdx, endRowIdx] = getRangeIndexes(); - selRowsData = rows.slice(startRowIdx, endRowIdx+1); - } else if(selectedCell.current?.[0]) { - selRowsData = [selectedCell.current[0]]; + lastGvSelectionRef.current = { type: 'columns', selectedColumns: new Set(selectedColumns) }; + } else { + lastGvSelectionRef.current = { type: 'all', selectedColumns: new Set() }; } - layoutDocker.openTab({ - id: PANELS.GEOMETRY, - title:gettext('Geometry Viewer'), - content: , - closable: true, - }, PANELS.MESSAGES, 'after-tab', true); + const selRowsData = getFilteredRowsForGeometryViewer(); + 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]); + }, [getFilteredRowsForGeometryViewer, openGeometryViewerTab, eventBus, selectedRows, selectedColumns]); + + // Auto-update Geometry Viewer when rows/columns change + useEffect(()=>{ + const rowsChanged = prevRowsRef.current !== rows; + const columnsChanged = prevColumnsRef.current !== columns; + const currentGeometryColumn = columns.find(col => col.cell === 'geometry' || col.cell === 'geography'); + + if((rowsChanged || columnsChanged) && layoutDocker.isTabOpen(PANELS.GEOMETRY)) { + + const prevColumnNames = prevColumnsRef.current?.map(c => c.key).sort().join(',') ?? ''; + const currColumnNames = columns.map(c => c.key).sort().join(','); + const columnsChanged = prevColumnNames !== currColumnNames; + + if(columnsChanged && currentGeometryColumn) { + gvClearedForColumnsRef.current = currColumnNames; + lastGvSelectionRef.current = { type: 'all', selectedColumns: new Set() }; + openGeometryViewerTab(null, []); + } else if(gvClearedForColumnsRef.current === currColumnNames) { + openGeometryViewerTab(null, []); + } else if(currentGeometryColumn && rowsChanged) { + const useColSelection = lastGvSelectionRef.current.type === 'columns'; + const selRowsData = getFilteredRowsForGeometryViewer(useColSelection); + openGeometryViewerTab(currentGeometryColumn, selRowsData); + } else { + // No geometry column + openGeometryViewerTab(null, []); + } + } + + prevRowsRef.current = rows; + prevColumnsRef.current = columns; + }, [rows, columns, getFilteredRowsForGeometryViewer, layoutDocker]); const triggerResetScroll = () => { // Reset the scroll position to previously saved location.