/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { Box, Card, CardContent, CardHeader, useTheme } from '@mui/material';
import { styled } from '@mui/material/styles';
import React, {useEffect} from 'react';
import _ from 'lodash';
import { PgButtonGroup, PgIconButton } from '../components/Buttons';
import ZoomInIcon from '@mui/icons-material/ZoomIn';
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
import ZoomOutMapIcon from '@mui/icons-material/ZoomOutMap';
import SaveAltIcon from '@mui/icons-material/SaveAlt';
import gettext from 'sources/gettext';
import ReactDOMServer from 'react-dom/server';
import url_for from 'sources/url_for';
import { downloadSvg } from './svg_download';
import CloseIcon from '@mui/icons-material/CloseRounded';
import PropTypes from 'prop-types';
import Table from '../components/Table';
const StyledBox = styled(Box)(({theme}) => ({
'& .Graphical-explainDetails': {
minWidth: '200px',
maxWidth: '300px',
position: 'absolute',
top: '0.25rem',
bottom: '0.25rem',
right: '0.25rem',
borderColor: theme.otherVars.borderColor,
// box-shadow: 0 0.125rem 0.5rem rgb(132 142 160 / 28%);
wordBreak: 'break-all',
display: 'flex',
flexDirection: 'column',
zIndex: 99,
'& .Graphical-explainContent': {
height: '100%',
overflow: 'auto',
'& .Graphical-tableBorderBottom':{
'& tbody tr:last-of-type td': {
borderBottom: '1px solid '+theme.otherVars.borderColor,
},
},
'& .Graphical-tablewrapTd': {
'& tbody td': {
whiteSpace: 'pre-wrap',
}
},
},
},
}
));
// Some predefined constants used to calculate image location and its border
let pWIDTH = 100;
let pHEIGHT = 100;
let IMAGE_WIDTH = 50;
let IMAGE_HEIGHT = 50;
let ARROW_WIDTH = 10,
ARROW_HEIGHT = 10;
let TXT_ALIGN = 5,
TXT_SIZE = '15px';
let xMargin = 25,
yMargin = 25;
let MIN_ZOOM_FACTOR = 0.3,
MAX_ZOOM_FACTOR = 2,
INIT_ZOOM_FACTOR = 1;
let ZOOM_RATIO = 0.05;
const AUXILIARY_KEYS = ['image', 'Plans', 'level', 'image_text', 'xpos', 'ypos', 'width', 'height', 'total_time', 'parent_node', '_serial', 'arr_id'];
function PolyLine({startx, starty, endx, endy, opts, arrowOpts}) {
// Calculate end point of first starting straight line (startx1, starty1)
// Calculate start point of 2nd straight line (endx1, endy1)
let midX1 = startx + ((endx - startx) / 3),
midX2 = startx + (2 * ((endx - startx) / 3));
return (
<>
>
);
}
PolyLine.propTypes = {
startx: PropTypes.number,
starty: PropTypes.number,
endx: PropTypes.number,
endy: PropTypes.number,
opts: PropTypes.object,
arrowOpts: PropTypes.object,
};
function Multitext({currentXpos, currentYpos, label, maxWidth}) {
const theme = useTheme();
let abc = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let xmlns = 'http://www.w3.org/2000/svg';
let svgElem = document.createElementNS(xmlns, 'svg');
svgElem.setAttributeNS(xmlns, 'height', '100%');
svgElem.setAttributeNS(xmlns, 'width', '100%');
let text = document.createElementNS(xmlns, 'text');
text.innerHTML = abc;
text.setAttributeNS(xmlns, 'x', 0);
text.setAttributeNS(xmlns, 'y', 0);
let attributes={
'font-size': TXT_SIZE,
'text-anchor': 'middle',
'fill': theme.palette.text.primary,
};
Object.keys(attributes).forEach((key)=>{
text.setAttributeNS(xmlns, key, attributes[key]);
});
svgElem.appendChild(text);
document.body.appendChild(svgElem);
/*
* Find letter width in pixels and
* index from where the text should be broken
*/
let letterWidth = text.getBBox().width / abc.length,
wordBreakIndex = Math.round((maxWidth / letterWidth)) - 1;
svgElem.remove();
let words = label?.split(' ') ?? '',
widthSoFar = 0,
lines = [],
currLine = '',
/*
* Function to divide string into multiple lines
* and store them in an array if it size crosses
* the max-width boundary.
*/
splitTextInMultiLine = function(leading, so_far, line) {
let l = line.length,
res = [];
if (l == 0)
return res;
if (so_far && (so_far + (l * letterWidth) > maxWidth)) {
res.push(leading);
res = res.concat(splitTextInMultiLine('', 0, line));
} else if (so_far) {
res.push(leading + ' ' + line);
} else {
if (leading)
res.push(leading);
if (line.length > wordBreakIndex + 1)
res.push(line.slice(0, wordBreakIndex) + '-');
else
res.push(line);
res = res.concat(splitTextInMultiLine('', 0, line.slice(wordBreakIndex)));
}
return res;
};
for (const word of words) {
let tmpArr = splitTextInMultiLine(
currLine, widthSoFar, word
);
if (currLine) {
lines = lines.slice(0, lines.length - 1);
}
lines = lines.concat(tmpArr);
currLine = lines[lines.length - 1];
widthSoFar = (currLine.length * letterWidth);
}
return (
{lines.map((line, i)=>{
if(i > 0) {
return {line};
}
return {line};
})}
);
}
Multitext.propTypes = {
currentXpos: PropTypes.number,
currentYpos: PropTypes.number,
label: PropTypes.string,
maxWidth: PropTypes.number,
};
function Image({plan, label, currentXpos, currentYpos, content, download, onNodeClick}) {
return (
<>
{download &&
}
>
);
}
Image.propTypes = {
plan: PropTypes.object,
label: PropTypes.string,
currentXpos: PropTypes.number,
currentYpos: PropTypes.number,
content: PropTypes.string,
download: PropTypes.bool,
onNodeClick: PropTypes.func,
};
function NodeDetails({plan, download=false}) {
return <>
{Object.keys(plan).map((key)=>{
if(AUXILIARY_KEYS.indexOf(key) != -1) {
return null;
}
let value = plan[key];
if(_.isArray(value)) {
value = value.map((v)=>{
if(typeof(v) == 'object') {
return JSON.stringify(v, null, 2);
}
return v;
});
}
if(download) {
return `${key}: ${value}\n`;
} else {
return (
{key} |
{`${value !== undefined ? value : ''}`} |
);
}
})}
>;
}
NodeDetails.propTypes = {
plan: PropTypes.object,
download: PropTypes.bool,
};
function PlanContent({plan, pXpos, pYpos, ...props}) {
const theme = useTheme();
let currentXpos = props.xpos + plan.xpos,
currentYpos = props.ypos + plan.ypos,
isSubPlan = (plan['Parent Relationship'] === 'SubPlan');
const nodeLabel = plan.Schema == undefined ?
plan.image_text : plan.Schema + '.' + plan.image_text;
let polylineProps = null;
if(!_.isUndefined(pYpos)) {
let arrowSize = props.ctx.arrows[plan['arr_id']];
polylineProps = {
startx: currentXpos + pWIDTH,
starty: currentYpos + (pHEIGHT / 2),
endx: pXpos - ARROW_WIDTH,
endy: pYpos + (pHEIGHT / 2),
arr_id: plan['arr_id'],
};
polylineProps.opts = {
stroke: theme.palette.text.primary,
strokeWidth: arrowSize + 2,
};
polylineProps.arrowOpts = {
style: {
markerEnd: `url("#${plan['arr_id']}")`,
}
};
}
return (
<>
{isSubPlan &&
<>
{plan['Subplan Name']}
>}
props.onNodeClick(nodeLabel, plan)}
/>
{polylineProps && }
{plan['Plans'].map((p, i)=>(
))}
>
);
}
PlanContent.propTypes = {
plan: PropTypes.object,
pXpos: PropTypes.number,
pYpos: PropTypes.number,
xpos: PropTypes.number,
ypos: PropTypes.number,
ctx: PropTypes.object,
download: PropTypes.bool,
onNodeClick: PropTypes.func,
};
function PlanSVG({planData, zoomFactor, fitZoomFactor, ...props}) {
const theme = useTheme();
useEffect(()=>{
fitZoomFactor?.(planData.width);
}, [planData.width]);
return (
);
}
PlanSVG.propTypes = {
planData: PropTypes.object,
zoomFactor: PropTypes.number,
fitZoomFactor: PropTypes.func,
ctx: PropTypes.object,
};
export default function Graphical({planData, ctx}) {
const graphContainerRef = React.useRef();
const [zoomFactor, setZoomFactor] = React.useState(INIT_ZOOM_FACTOR);
const [[explainPlanTitle, explainPlanDetails], setExplainPlanDetails] = React.useState([null, null]);
const onCmdClick = (cmd)=>{
if(cmd == 'in') {
setZoomFactor((prev)=>{
if(prev >= MAX_ZOOM_FACTOR) return prev;
return prev+ZOOM_RATIO;
});
} else if(cmd == 'out') {
setZoomFactor((prev)=>{
if(prev <= MIN_ZOOM_FACTOR) return prev;
return prev-ZOOM_RATIO;
});
} else {
setZoomFactor(INIT_ZOOM_FACTOR);
}
};
const fitZoomFactor = React.useCallback((svgWidth)=>{
/*
* Scale graph in case its width is bigger than panel width
* in which the graph is displayed
*/
if(graphContainerRef.current.offsetWidth && svgWidth) {
let zoomFactor = graphContainerRef.current.offsetWidth/svgWidth;
zoomFactor = zoomFactor < MIN_ZOOM_FACTOR ? MIN_ZOOM_FACTOR : zoomFactor;
zoomFactor = zoomFactor > INIT_ZOOM_FACTOR ? INIT_ZOOM_FACTOR : zoomFactor;
setZoomFactor(zoomFactor);
}
}, []);
const onDownloadClick = ()=>{
downloadSvg(ReactDOMServer.renderToStaticMarkup(
{/*This is intentional (SonarQube)*/}}/>
), 'explain_plan_' + (new Date()).getTime() + '.svg');
};
const onNodeClick = React.useCallback((title, details)=>{
setExplainPlanDetails([title, details]);
}, []);
useEffect(()=>{
setExplainPlanDetails([null, null]);
}, [planData]);
return (
} onClick={()=>onCmdClick('in')}/>
} onClick={()=>onCmdClick()}/>
} onClick={()=>onCmdClick('out')}/>
} onClick={onDownloadClick}/>
{Boolean(explainPlanDetails) &&
{explainPlanTitle}
} size="xs" noBorder onClick={()=>setExplainPlanDetails([null, null])}/>
} />
}
);
}
Graphical.propTypes = {
planData: PropTypes.object,
ctx: PropTypes.object,
};