#add deps
yarn add --dev react-spring 3d-force-graph blueimp-md5 dependency-cruiser
yarn add @material-ui/core @material-ui/icons @material-ui/lab
#create deps file
node_modules/.bin/depcruise --exclude "^node_modules" --output-type json assets/js > deps.json
Last active
January 13, 2020 09:37
-
-
Save Slavenin/fec07f8eb07f6721e666f561e86a847d to your computer and use it in GitHub Desktop.
Vizualize graph of your react app dependencies like a boss
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useEffect } from 'react'; | |
import ForceGraph3D from '3d-force-graph'; | |
import md5 from 'blueimp-md5'; | |
import { makeStyles } from '@material-ui/core/styles'; | |
import Close from '@material-ui/icons/Close'; | |
import Input from '@material-ui/core/Input'; | |
import InputAdornment from '@material-ui/core/InputAdornment'; | |
import IconButton from '@material-ui/core/IconButton'; | |
import SelectedTree from './Tree'; | |
//!!!change this path to data file from prev step | |
import data from '../../../../deps.json' | |
const useStyles = makeStyles(() => ({ | |
root: { | |
position: 'absolute', | |
zIndex: 1, | |
backgroundColor: 'white' | |
}, | |
searchList: { | |
position: 'absolute', | |
zIndex: 1, | |
backgroundColor: 'white', | |
marginTop: '35px', | |
maxWidth: '257px', | |
maxHeight: '500px', | |
overflow: 'auto' | |
} | |
})); | |
//да это не стиль реакта, но всунуть ForceGraph3D в стейт нельзя | |
//заводить под это отдельный контекст выглядит как оверхед | |
//если всё усложнится, то можно вынести эти переменные в | |
// отдельный контекст и там всё делать | |
const myGraph = ForceGraph3D(); | |
let highlightNodes = []; | |
let highlightLink = []; | |
let globalQ = ''; | |
let files = { | |
nodes: [], | |
links: [] | |
}; | |
function hashCode(str) { // java String#hashCode | |
var hash = 0; | |
for (var i = 0; i < str.length; i++) { | |
hash = str.charCodeAt(i) + ((hash << 5) - hash); | |
} | |
return hash; | |
} | |
function intToRGB(i) { | |
var c = (i & 0x00FFFFFF) | |
.toString(16) | |
.toUpperCase(); | |
return "00000".substring(0, 6 - c.length) + c; | |
} | |
const DepsViz = () => { | |
let divRef = React.createRef(); | |
const classes = useStyles(); | |
const [search, setSearch] = React.useState(''); | |
const handleChange = prop => event => { | |
setSearch(event.target.value); | |
}; | |
const handleClickShowSearch = () => { | |
setSearch(''); | |
}; | |
const handleMouseDownSearch = event => { | |
event.preventDefault(); | |
}; | |
const handleNodeClick = node => { | |
highlightNodes = [node]; | |
const links = files.links.filter((el) => el.source.id === node.id); | |
highlightNodes = [...highlightNodes, ...links.map(({ target }) => target)]; | |
highlightLink = links; | |
updateHighlight(); | |
}; | |
//хз что тм либа делает с колбэками, но на изменение стейта им всё-равно | |
const updateNodeColor = node => ( | |
(highlightNodes.indexOf(node) === -1) | |
? ( | |
(globalQ === '' && !highlightNodes.length) | |
? node.color | |
: 'rgba(0,255,255,0.6)' | |
) | |
: 'rgb(255,0,0,1)' | |
); | |
const directionalParticles = link => (highlightLink.indexOf(link) !== -1) ? 4 : 0; | |
const linkWidth = link => (highlightLink.indexOf(link) !== -1) ? 4 : 2; | |
useEffect(() => { | |
data.modules.forEach((el) => { | |
let hash = md5(el.source); | |
let deps = []; | |
let sName = el.source.replace('assets/js', ''); | |
el.dependencies.forEach((d) => { | |
let id = md5(d.resolved); | |
let name = d.resolved.replace('assets/js', ''); | |
deps.push({id, name}); | |
files.links.push({ | |
source: hash, | |
target: id, | |
name: `${sName} >>> ${name}` | |
}) | |
}); | |
files.nodes.push({ | |
id: hash, | |
deps, | |
color: "#" + intToRGB(hashCode(hash)), | |
name: sName, | |
val: 10 * el.dependencies.length / 2, | |
group: hash | |
}); | |
}); | |
myGraph(divRef.current) | |
.enableNodeDrag(false) | |
.enableNavigationControls(true) | |
.linkOpacity(0.5) | |
.graphData(files) | |
.nodeColor(updateNodeColor) | |
.linkWidth(linkWidth) | |
.linkDirectionalParticles(directionalParticles) | |
.linkDirectionalParticleWidth(4) | |
.linkDirectionalArrowLength(3.5) | |
.linkDirectionalArrowRelPos(1) | |
.onBackgroundClick(() => { | |
highlightNodes = []; | |
highlightLink = []; | |
setSearch(''); | |
updateHighlight(); | |
}) | |
.onNodeClick(handleNodeClick) | |
}, []); | |
useEffect(() => { | |
globalQ = search; | |
highlightLink = []; | |
if (search) { | |
const reg = new RegExp(search, 'ui'); | |
highlightNodes = files.nodes.filter((el) => el.name.search(reg) !== -1); | |
} else { | |
highlightNodes = []; | |
} | |
updateHighlight(); | |
}, [search]); | |
const updateHighlight = () => { | |
myGraph | |
.nodeColor(myGraph.nodeColor()) | |
.linkWidth(myGraph.linkWidth()) | |
.linkDirectionalParticles(myGraph.linkDirectionalParticles()); | |
}; | |
return ( | |
<> | |
<Input | |
className={classes.root} | |
id="standard-adornment-search" | |
value={search} | |
onChange={handleChange('search')} | |
endAdornment={ | |
<InputAdornment position="end"> | |
<IconButton | |
aria-label="toggle search visibility" | |
onClick={handleClickShowSearch} | |
onMouseDown={handleMouseDownSearch} | |
> | |
<Close/> | |
</IconButton> | |
</InputAdornment> | |
} | |
/> | |
{search !== '' | |
&& ( | |
<div className={classes.searchList}> | |
<SelectedTree list={highlightNodes} /> | |
</div> | |
)} | |
<div ref={divRef}></div> | |
</> | |
); | |
}; | |
export default DepsViz; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from 'react'; | |
import { fade, makeStyles, withStyles } from '@material-ui/core/styles'; | |
import SvgIcon from '@material-ui/core/SvgIcon'; | |
import TreeView from '@material-ui/lab/TreeView'; | |
import TreeItem from '@material-ui/lab/TreeItem'; | |
import Collapse from '@material-ui/core/Collapse'; | |
import { useSpring, animated } from 'react-spring/web.cjs'; // web.cjs is required for IE 11 support | |
function MinusSquare(props) { | |
return ( | |
<SvgIcon fontSize="inherit" {...props}> | |
{/* tslint:disable-next-line: max-line-length */} | |
<path | |
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z"/> | |
</SvgIcon> | |
); | |
} | |
function PlusSquare(props) { | |
return ( | |
<SvgIcon fontSize="inherit" {...props}> | |
{/* tslint:disable-next-line: max-line-length */} | |
<path | |
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 12.977h-4.923v4.896q0 .401-.281.682t-.682.281v0q-.375 0-.669-.281t-.294-.682v-4.896h-4.923q-.401 0-.682-.294t-.281-.669v0q0-.401.281-.682t.682-.281h4.923v-4.896q0-.401.294-.682t.669-.281v0q.401 0 .682.281t.281.682v4.896h4.923q.401 0 .682.281t.281.682v0q0 .375-.281.669t-.682.294z"/> | |
</SvgIcon> | |
); | |
} | |
function CloseSquare(props) { | |
return ( | |
<SvgIcon className="close" fontSize="inherit" {...props}> | |
{/* tslint:disable-next-line: max-line-length */} | |
<path | |
d="M17.485 17.512q-.281.281-.682.281t-.696-.268l-4.12-4.147-4.12 4.147q-.294.268-.696.268t-.682-.281-.281-.682.294-.669l4.12-4.147-4.12-4.147q-.294-.268-.294-.669t.281-.682.682-.281.696 .268l4.12 4.147 4.12-4.147q.294-.268.696-.268t.682.281 .281.669-.294.682l-4.12 4.147 4.12 4.147q.294.268 .294.669t-.281.682zM22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0z"/> | |
</SvgIcon> | |
); | |
} | |
function TransitionComponent(props) { | |
const style = useSpring({ | |
from: { opacity: 0, transform: 'translate3d(20px,0,0)' }, | |
to: { opacity: props.in ? 1 : 0, transform: `translate3d(${props.in ? 0 : 20}px,0,0)` }, | |
}); | |
return ( | |
<animated.div style={style}> | |
<Collapse {...props} /> | |
</animated.div> | |
); | |
} | |
const StyledTreeItem = withStyles(theme => ({ | |
iconContainer: { | |
'& .close': { | |
opacity: 0.3, | |
}, | |
}, | |
group: { | |
marginLeft: 12, | |
paddingLeft: 12, | |
borderLeft: `1px dashed ${fade(theme.palette.text.primary, 0.4)}`, | |
}, | |
}))(props => <TreeItem {...props} TransitionComponent={TransitionComponent}/>); | |
const useStyles = makeStyles({ | |
root: { | |
flexGrow: 1 | |
}, | |
}); | |
const SelectedTree = ({ list }) => { | |
const classes = useStyles(); | |
return ( | |
<TreeView | |
className={classes.root} | |
defaultExpanded={['1']} | |
defaultCollapseIcon={<MinusSquare/>} | |
defaultExpandIcon={<PlusSquare/>} | |
defaultEndIcon={<CloseSquare/>} | |
> | |
{list.map((el) => ( | |
<StyledTreeItem key={el.id} nodeId={el.id} label={el.name}> | |
{el.deps.map((d) => <StyledTreeItem key={d.id} nodeId={d.id} label={d.name}/>)} | |
</StyledTreeItem> | |
))} | |
</TreeView> | |
); | |
}; | |
export default SelectedTree; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment