Last active
February 16, 2021 12:38
-
-
Save Slavenin/fc5866273157618b8336a1aeddff46e3 to your computer and use it in GitHub Desktop.
class dependency graph visualization in 3d space with threejs
This file contains 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
{% extends 'base.html.twig' %} | |
{% block stylesheets %} | |
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" | |
integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1" crossorigin="anonymous"> | |
{% endblock %} | |
{% block javascripts %} | |
{{ parent() }} | |
<script src="//unpkg.com/3d-force-graph"></script> | |
<script src="//unpkg.com/three"></script> | |
<script src="//unpkg.com/three-spritetext"></script> | |
<script src="//unpkg.com/three@0.125.2/examples/js/utils/BufferGeometryUtils.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/js/bootstrap.bundle.min.js" | |
integrity="sha384-ygbV9kiqUc6oa4msXn9868pTtWMgiQaeYH7/t7LECLbyPA2x65Kgf80OJFdroafW" | |
crossorigin="anonymous"></script> | |
<script> | |
var myGraph = ForceGraph3D(); | |
var data = JSON.parse(document.getElementById('data').innerText) | |
var loader = new THREE.FontLoader(); | |
loader.load('https://raw.githubusercontent.com/rollup/three-jsnext/master/examples/fonts/gentilis_bold.typeface.json', function (font) { | |
createGraph(font); | |
}); | |
function createGraph(font) { | |
var gObj = myGraph(document.getElementById('graph')) | |
.enableNodeDrag(false) | |
.enableNavigationControls(true) | |
.nodeAutoColorBy('group') | |
.nodeLabel('name') | |
.linkOpacity(0.5) | |
.graphData(data) | |
.nodeThreeObject(function (d) { | |
var obj = null; | |
if (d.type === 'class') obj = new THREE.SphereGeometry(14) | |
else if (d.type === 'method') obj = new THREE.DodecahedronGeometry(7) | |
else if (d.type === 'parameter') obj = new THREE.BoxGeometry(3, 3, 3) | |
else if (d.type === 'property') obj = new THREE.BoxGeometry(7, 7, 7) | |
var objMaterial = new THREE.MeshLambertMaterial({ | |
color: d.color, | |
opacity: 0.8, | |
transparent: true | |
}); | |
return (new THREE.Mesh(obj, objMaterial)); | |
}) | |
.linkWidth(3) | |
.linkDirectionalParticleWidth(3) | |
.linkDirectionalArrowLength(7) | |
.linkDirectionalArrowRelPos(0.5); | |
var scene = gObj.scene(); | |
var spriteIds = []; | |
var id = 1; | |
setTimeout(function () { | |
var children = gObj.scene().children[3].children; | |
for (var i =0; i < children.length; i++) | |
{ | |
var cur = children[i]; | |
if(cur.__data.name && cur.__data.type === 'class') | |
{ | |
var sprite = new SpriteText(cur.__data.name.split('\\').slice(-2).join('\\')); | |
sprite.material.depthWrite = false; | |
sprite.color = cur.__data.color; | |
sprite.textHeight = 8; | |
sprite.position.set(cur.__data.x, cur.__data.y + 17, cur.__data.z) | |
sprite.name = id++; | |
scene.add(sprite) | |
spriteIds.push(sprite.name); | |
} | |
} | |
}, 15000) | |
} | |
</script> | |
{% endblock %} | |
{% block body %} | |
<span id="data" style="display: none;">{{ data }}</span> | |
<div class="container-fluid"> | |
<div class="row"> | |
<div class="col-12"> | |
<form class="row"> | |
<div class="col-2"> | |
<label for="namespaces" class="form-label">Namespaces filter</label> | |
</div> | |
<div class="col-6"> | |
<textarea name="namespaces" class="form-control col-4" id="namespaces" | |
aria-describedby="namespacesHelp">{{ app.request.query.get('namespaces') }}</textarea> | |
<div id="namespacesHelp" class="form-text">One per line. You can use * for mask</div> | |
</div> | |
<div class="col-4"> | |
<button type="submit" class="btn btn-primary mb-3">Filter</button> | |
</div> | |
</form> | |
</div> | |
</div> | |
<div class="row"> | |
<div id="graph" class="col-12"></div> | |
</div> | |
</div> | |
{% endblock %} |
This file contains 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
<?php | |
namespace App\Http\Controller\Graph; | |
use Doctrine\Common\Annotations\AnnotationReader; | |
use Doctrine\Common\Annotations\Reader; | |
use Doctrine\Common\Collections\Collection; | |
use Doctrine\ORM\Mapping\ManyToMany; | |
use Doctrine\ORM\Mapping\ManyToOne; | |
use Doctrine\ORM\Mapping\OneToMany; | |
use Doctrine\ORM\Mapping\OneToOne; | |
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | |
use Symfony\Component\HttpFoundation\Request; | |
use Symfony\Component\HttpFoundation\Response; | |
use Symfony\Component\Routing\Annotation\Route; | |
class GraphController extends AbstractController | |
{ | |
private const TYPE_CLASS = 'class'; | |
private const TYPE_METHOD = 'method'; | |
private const TYPE_PARAMETER = 'parameter'; | |
private const TYPE_PROPERTY = 'property'; | |
private array $result = [ | |
'nodes' => [], | |
'links' => [], | |
]; | |
private array $classes = []; | |
private array $addedLinks = []; | |
private Reader $reader; | |
/** | |
* GraphController constructor. | |
* @param Reader $reader | |
*/ | |
public function __construct() | |
{ | |
$this->reader = new AnnotationReader(); | |
} | |
/** | |
* @Route("/by-methods", methods={"GET"}) | |
*/ | |
public function graphAll(Request $request): Response | |
{ | |
$this->addClassesForAll($this->getFilters($request)); | |
return $this->render('graph/graph.html.twig', ['data' => json_encode($this->result)]); | |
} | |
/** | |
* @Route("/by-class", methods={"GET"}) | |
*/ | |
public function graphClass(Request $request): Response | |
{ | |
$this->addClassesAndMethods($this->getFilters($request)); | |
return $this->render('graph/graph.html.twig', ['data' => json_encode($this->result)]); | |
} | |
private function getFilters(Request $request) | |
{ | |
$namespaces = $request->query->get("namespaces", false); | |
$filter = ['/App\\\\/']; | |
if (false !== $namespaces && '' !== $namespaces) { | |
$filter = array_map('trim', explode("\n", $namespaces)); | |
$filter = array_map('preg_quote', $filter); | |
foreach ($filter as &$f) { | |
//какой п.ц но preg_quote не правильно экранирут слеши в данной ситуации | |
$f = '/' . str_replace('*', '.*', str_replace('\\\\\\', '\\\\', $f)) . '/i'; | |
} | |
} | |
return $filter; | |
} | |
private function addClassesAndMethods(array $filter) | |
{ | |
$classMap = []; | |
$this->getClassMap($classMap, $filter); | |
$added = []; | |
foreach ($this->classes as $class) { | |
$classCountChildren = 0; | |
$reflCls = new \ReflectionClass($class); | |
$classKey = md5($class); | |
$this->addProperty($reflCls, $class, $classKey, $added, $classMap); | |
if (!isset($added[$classKey])) { | |
$this->result['nodes'][] = [ | |
'id' => $classKey, | |
'group' => $classKey, | |
'name' => $class, | |
'count_children' => $classCountChildren, | |
'type' => self::TYPE_CLASS, | |
]; | |
} | |
$this->addParents($reflCls, $classKey, $added, $classMap); | |
$this->addInterfaces($reflCls, $classKey, $added, $classMap); | |
$this->addTraits($reflCls, $classKey, $added, $classMap); | |
//@todo научится вычислять трейты | |
$added[$classKey] = true; | |
} | |
} | |
private function addParents(\ReflectionClass $reflCls, $classKey, &$added, $classMap) | |
{ | |
$parent = $reflCls->getParentClass(); | |
if (false !== $parent) { | |
$parentName = $parent->getName(); | |
$parentKey = md5($parentName); | |
$linkKey = md5($classKey . $parentKey); | |
$id = $this->addClassIfNotExists($added, $parentKey, $parentName, $classMap, true); | |
$this->addLinkIfNotExists($linkKey, $classKey, $id); | |
$this->addInterfaces($parent, $id, $added, $classMap); | |
$this->addTraits($parent, $id, $added, $classMap); | |
$this->addParents($parent, $id, $added, $classMap); | |
} | |
} | |
private function addLinkIfNotExists($linkKey, $source, $target) | |
{ | |
if (!isset($this->addedLinks[$linkKey])) { | |
$this->result['links'][] = [ | |
'source' => $source, | |
'target' => $target, | |
]; | |
$this->addedLinks[$linkKey] = true; | |
} | |
} | |
private function addInterfaces(\ReflectionClass $reflCls, $classKey, &$added, $classMap) | |
{ | |
$interfaces = $reflCls->getInterfaceNames(); | |
foreach ($interfaces as $interface) { | |
$key = md5($interface); | |
$linkKey = md5($classKey . $key); | |
$id = $this->addClassIfNotExists($added, $key, $interface, $classMap, true); | |
$this->addLinkIfNotExists($linkKey, $classKey, $id); | |
} | |
} | |
private function addTraits(\ReflectionClass $reflCls, $classKey, &$added, $classMap) | |
{ | |
$traits = $reflCls->getTraitNames(); | |
foreach ($traits as $trait) { | |
$key = md5($trait); | |
$linkKey = md5($classKey . $key); | |
$id = $this->addClassIfNotExists($added, $key, $trait, $classMap, true); | |
$this->addLinkIfNotExists($linkKey, $classKey, $id); | |
} | |
} | |
private function getClassMap(&$classMap, $filter) | |
{ | |
$res = get_declared_classes(); | |
$autoloaderClassName = ''; | |
foreach ($res as $className) { | |
if (strpos($className, 'ComposerAutoloaderInit') === 0) { | |
$autoloaderClassName = $className; | |
break; | |
} | |
} | |
$classLoader = $autoloaderClassName::getLoader(); | |
foreach ($classLoader->getClassMap() as $cl => $path) { | |
if ($this->classInFilter($cl, $filter)) { | |
$this->classes[] = $cl; | |
} | |
$classMap[md5($cl)] = $cl; | |
} | |
} | |
private function classInFilter($class, $filter) | |
{ | |
foreach ($filter as $f) { | |
if (preg_match($f, $class) !== 0 && stristr($class, '\\Tests') === false) { | |
return true; | |
} | |
} | |
return false; | |
} | |
private function addClassesForAll(array $filter) | |
{ | |
$classMap = []; | |
$this->getClassMap($classMap, $filter); | |
$added = []; | |
foreach ($this->classes as $class) { | |
$classCountChildren = 0; | |
$reflCls = new \ReflectionClass($class); | |
$classKey = md5($class); | |
$this->addMethods($reflCls, $class, $classKey, $classCountChildren, $added, $classMap); | |
$this->result['nodes'][] = [ | |
'id' => $classKey, | |
'group' => $classKey, | |
'name' => $class, | |
'count_children' => $classCountChildren, | |
'type' => self::TYPE_CLASS, | |
]; | |
$added[$classKey] = true; | |
} | |
} | |
private function addMethods( | |
\ReflectionClass $reflCls, | |
string $class, | |
string $classKey, | |
int &$classCountChildren, | |
array &$added, | |
array $classMap | |
) { | |
foreach ($reflCls->getMethods() as $method) { | |
if (!$method->isPublic() || preg_match( | |
'/^(get|set|add|remove).*/', | |
$method->getName() | |
) || $class !== $method->getDeclaringClass() | |
->getName()) { | |
continue; | |
} | |
$classCountChildren++; | |
$mKey = md5($class . $method->getName()); | |
$this->addParameters($method, $mKey, $added, $classMap); | |
//добавляем метод | |
$this->result['nodes'][] = [ | |
'id' => $mKey, | |
'name' => $method->getName(), | |
'group' => $classKey, | |
'count_children' => count($method->getParameters()), | |
'type' => self::TYPE_METHOD, | |
]; | |
//и связываем его с классом | |
$this->result['links'][] = [ | |
'source' => $classKey, | |
'target' => $mKey, | |
]; | |
} | |
} | |
private function addParameters(\ReflectionMethod $method, string $mKey, array &$added, array $classMap) | |
{ | |
foreach ($method->getParameters() as $parameter) { | |
$this->addLinkForType($parameter, $mKey, $classMap, $added); | |
} | |
} | |
private function addProperty( | |
\ReflectionClass $reflCls, | |
string $class, | |
string $classKey, | |
array &$added, | |
array $classMap | |
) { | |
foreach ($reflCls->getProperties() as $property) { | |
$prpId = md5($class . $property->getName()); | |
$this->addLinkForType($property, $prpId, $classMap, $added); | |
$this->result['nodes'][] = [ | |
'id' => $prpId, | |
'name' => $property->getName(), | |
'group' => $classKey, | |
'type' => self::TYPE_PROPERTY, | |
]; | |
$this->result['links'][] = [ | |
'source' => $prpId, | |
'target' => $classKey, | |
]; | |
} | |
} | |
/** | |
* @param $prop \ReflectionProperty|\ReflectionParameter | |
*/ | |
private function addLinkForType($prop, string $prpId, $classMap, &$added) | |
{ | |
if ($prop->getType()) { | |
$typeName = $this->addTypeForCollectionFromDoc( | |
$prop, | |
$prop->getType() | |
->getName() | |
); | |
$argTypeNameId = md5($typeName); | |
$id = $this->addClassIfNotExists($added, $argTypeNameId, $typeName, $classMap); | |
if (isset($classMap[$argTypeNameId])) { | |
$this->result['links'][] = [ | |
'source' => $id, | |
'target' => $prpId, | |
]; | |
} | |
} | |
} | |
private function addTypeForCollectionFromDoc($prop, $typeName) | |
{ | |
if ($typeName === Collection::class && $prop instanceof \ReflectionProperty) { | |
$myAnnotations = $this->reader->getPropertyAnnotations($prop); | |
if ($myAnnotations) { | |
foreach ($myAnnotations as $anot) { | |
if ($anot instanceof OneToMany || $anot instanceof OneToOne || $anot instanceof ManyToMany || $anot instanceof ManyToOne) { | |
$typeName = $anot->targetEntity; | |
if (!strstr($typeName, '\\')) { | |
$typeName = $prop->getDeclaringClass() | |
->getNamespaceName() . '\\' . $typeName; | |
} | |
break; | |
} | |
} | |
} | |
} elseif ($typeName === 'array') { | |
if ($prop instanceof \ReflectionParameter) { | |
//@todo | |
// $docCom = $prop->getDeclaringFunction()->getDocComment(); | |
//тут надо сделать сложную логику для вычисления из коммента функции | |
//типа аргумента по его индексу, при этом не ясно возможно ли это, | |
// так как параметры могут быть недокументированы или документированы частично | |
//пока нет времени этим заниматься.... | |
return $typeName; | |
} elseif ($prop instanceof \ReflectionProperty) { | |
$docCom = $prop->getDocComment(); | |
} | |
if (false !== $docCom) { | |
$matches = []; | |
preg_match('/@[\w]+\s+(.*)\[]/m', $docCom, $matches); | |
if (!empty($matches)) { | |
$typeName = $matches[1]; | |
if (!strstr($typeName, '\\')) { | |
$typeName = $prop->getDeclaringClass() | |
->getNamespaceName() . '\\' . $typeName; | |
} | |
} | |
} | |
} | |
return $typeName; | |
} | |
private function addClassIfNotExists( | |
array &$added, | |
string $id, | |
string $className, | |
$classMap, | |
$isExtends = false | |
): string { | |
if (!isset($added[$id]) && (isset($classMap[$id]) || $isExtends)) { | |
//если это класс, который не запрашивался, | |
//то мы его ни с чем не связываем | |
//исключения для классов с наследованием | |
if (!in_array($className, $this->classes)) { | |
$id = uniqid(); | |
} | |
$added[$id] = true; | |
$this->result['nodes'][] = [ | |
'id' => $id, | |
'name' => $className, | |
'group' => $id, | |
'type' => self::TYPE_CLASS, | |
]; | |
} | |
return $id; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment