Skip to content

Instantly share code, notes, and snippets.

@Slavenin
Last active February 16, 2021 12:38
Show Gist options
  • Save Slavenin/fc5866273157618b8336a1aeddff46e3 to your computer and use it in GitHub Desktop.
Save Slavenin/fc5866273157618b8336a1aeddff46e3 to your computer and use it in GitHub Desktop.
class dependency graph visualization in 3d space with threejs
{% 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 %}
<?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