pgadmin4/web/pgadmin/static/js/Explain/Graphical.jsx

456 lines
14 KiB
JavaScript

/////////////////////////////////////////////////////////////
//
// 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 (
<>
<line x1={startx} x2={midX1} y1={starty} y2={starty} {...opts}></line>
<line x1={midX1-1} x2={midX2} y1={starty} y2={endy} {...opts}></line>
<line x1={midX2} x2={endx} y1={endy} y2={endy} {...opts} {...arrowOpts}></line>
</>
);
}
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 (
<text x={currentXpos} y={currentYpos} fill={theme.palette.text.primary} style={{fontSize: TXT_SIZE, textAnchor: 'middle'}}>
{lines.map((line, i)=>{
if(i > 0) {
return <tspan key={i} dy="1.2em" x={currentXpos}>{line}</tspan>;
}
return <tspan key={i}>{line}</tspan>;
})}
</text>
);
}
Multitext.propTypes = {
currentXpos: PropTypes.number,
currentYpos: PropTypes.number,
label: PropTypes.string,
maxWidth: PropTypes.number,
};
function Image({plan, label, currentXpos, currentYpos, content, download, onNodeClick}) {
return (
<>
<image href={content}
preserveAspectRatio="none"
x={currentXpos + (pWIDTH - IMAGE_WIDTH) / 2}
y={currentYpos + (pHEIGHT - IMAGE_HEIGHT) / 2}
width={IMAGE_WIDTH} height={IMAGE_HEIGHT}
style={{cursor: 'pointer'}} onClick={onNodeClick}>
{download &&
<title>
<NodeDetails plan={plan} download={true} />
</title>
}
</image>
<Multitext currentXpos={currentXpos + (pWIDTH / 2) + TXT_ALIGN} currentYpos={currentYpos + pHEIGHT - TXT_ALIGN} label={label} maxWidth={150} />
</>
);
}
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 (<tr key={key}>
<td>{key}</td>
<td>{`${value !== undefined ? value : ''}`}</td>
</tr>);
}
})}
</>;
}
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 (
<>
<g>
{isSubPlan &&
<>
<rect x={currentXpos - plan.width + pWIDTH + xMargin}
y={currentYpos - plan.height + pHEIGHT + yMargin - TXT_ALIGN}
width={plan.width - xMargin}
height={plan.height + (currentYpos - yMargin)}
rx={5}
stroke="#444444"
strokeWidth={1.2}
fill="gray"
fillOpacity={0.2}
pointerEvents="none"
/>
<tspan x={currentXpos + pWIDTH - (plan.width / 2) - xMargin}
y={currentYpos + pHEIGHT - (plan.height / 2) - yMargin}
fontSize={TXT_SIZE}
textAnchor="start"
fill="red"
>{plan['Subplan Name']}</tspan>
</>}
<Image
label={nodeLabel}
content={url_for('misc.index') + 'static/explain/img/' + plan.image}
currentXpos={currentXpos}
currentYpos={currentYpos}
plan={plan}
download={props.download}
onNodeClick={()=>props.onNodeClick(nodeLabel, plan)}
/>
{polylineProps && <PolyLine {...polylineProps} />}
</g>
{plan['Plans'].map((p, i)=>(
<PlanContent key={i} plan={p} pXpos={currentXpos} pYpos={currentYpos} {...props}/>
))}
</>
);
}
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 (
<svg height={planData.height*zoomFactor} width={planData.width*zoomFactor} version="1.1" xmlns="http://www.w3.org/2000/svg">
<defs>
{Object.keys(props.ctx.arrows).map((arr_id, i)=>{
let arrowPoints = [
0, 0,
0, (ARROW_WIDTH / 2),
ARROW_HEIGHT, (ARROW_WIDTH / 4),
0, 0
].join(',');
let viewBox = [0, 0, 2 * ARROW_WIDTH, 2 * ARROW_HEIGHT].join(' ');
return(
<marker key={i} viewBox={viewBox} markerWidth={ARROW_WIDTH} markerHeight={ARROW_HEIGHT} orient="auto" refX="0" refY={ARROW_WIDTH / 4} id={arr_id}>
<polygon points={arrowPoints} fill={theme.palette.text.primary}></polygon>
</marker>
);
})}
</defs>
<g transform={`matrix(${zoomFactor},0,0,${zoomFactor},0,0)`}>
<rect x="0" y="0" width={planData.width} height={planData.height} rx="5" ry="5" fill={theme.palette.background.default}></rect>
<PlanContent plan={planData['Plan']} xpos={planData.width - xMargin} ypos={yMargin} {...props}/>
</g>
</svg>
);
}
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(
<PlanSVG planData={planData} download={true} ctx={ctx} zoomFactor={INIT_ZOOM_FACTOR} onNodeClick={()=>{/*This is intentional (SonarQube)*/}}/>
), 'explain_plan_' + (new Date()).getTime() + '.svg');
};
const onNodeClick = React.useCallback((title, details)=>{
setExplainPlanDetails([title, details]);
}, []);
return (
<StyledBox ref={graphContainerRef} height="100%" width="100%" overflow="auto">
<Box position="absolute" top="4px" left="4px" gap="4px" display="flex">
<PgButtonGroup size="small">
<PgIconButton title={gettext('Zoom in')} icon={<ZoomInIcon />} onClick={()=>onCmdClick('in')}/>
<PgIconButton title={gettext('Zoom to original')} icon={<ZoomOutMapIcon />} onClick={()=>onCmdClick()}/>
<PgIconButton title={gettext('Zoom out')} icon={<ZoomOutIcon />} onClick={()=>onCmdClick('out')}/>
</PgButtonGroup>
<PgButtonGroup size="small">
<PgIconButton title={gettext('Download')} icon={<SaveAltIcon />} onClick={onDownloadClick}/>
</PgButtonGroup>
</Box>
<PlanSVG planData={planData} ctx={ctx} zoomFactor={zoomFactor} fitZoomFactor={fitZoomFactor}
onNodeClick={onNodeClick}
/>
{Boolean(explainPlanDetails) &&
<Card className='Graphical-explainDetails' data-label="explain-details">
<CardHeader title={<Box display="flex">
{explainPlanTitle}
<Box marginLeft="auto">
<PgIconButton title={gettext('Close')} icon={<CloseIcon />} size="xs" noBorder onClick={()=>setExplainPlanDetails([null, null])}/>
</Box>
</Box>} />
<CardContent className='Graphical-explainContent'>
<Table classNameRoot={'Graphical-tableBorderBottom Graphical-tablewrapTd'}>
<tbody>
<NodeDetails download={false} plan={explainPlanDetails} />
</tbody>
</Table>
</CardContent>
</Card>}
</StyledBox>
);
}
Graphical.propTypes = {
planData: PropTypes.object,
ctx: PropTypes.object,
};