mirror of
https://github.com/pgadmin-org/pgadmin4.git
synced 2025-02-03 12:10:55 -06:00
456 lines
14 KiB
JavaScript
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,
|
|
};
|