mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
refactor(devtools): abstract and reuse the tree visualizer (#62264)
Abstract the injector tree visualizer so it can be used for both the Injector Tree and Router Tree tabs without having to rely on separate identical implementations. PR Close #62264
This commit is contained in:
parent
e63cd1f405
commit
3eec4badab
24 changed files with 2017 additions and 2085 deletions
|
|
@ -1,16 +0,0 @@
|
|||
load("//devtools/tools:typescript.bzl", "ts_project")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
ts_project(
|
||||
name = "injector_tree_visualizer",
|
||||
srcs = ["injector-tree-visualizer.ts"],
|
||||
interop_deps = [
|
||||
"//packages/core",
|
||||
],
|
||||
deps = [
|
||||
"//:node_modules/@types/d3",
|
||||
"//:node_modules/d3",
|
||||
"//devtools/projects/protocol:protocol_rjs",
|
||||
],
|
||||
)
|
||||
|
|
@ -1,306 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import * as d3 from 'd3';
|
||||
import {SerializedInjector} from '../../../../../protocol';
|
||||
|
||||
let arrowDefId = 0;
|
||||
|
||||
const injectorTypeToClassMap = new Map<SerializedInjector['type'], string>([
|
||||
['imported-module', 'node-imported-module'],
|
||||
['environment', 'node-environment'],
|
||||
['element', 'node-element'],
|
||||
['null', 'node-null'],
|
||||
]);
|
||||
|
||||
export interface InjectorTreeNode {
|
||||
injector: SerializedInjector;
|
||||
children: InjectorTreeNode[];
|
||||
}
|
||||
|
||||
export type InjectorTreeD3Node = d3.HierarchyPointNode<InjectorTreeNode>;
|
||||
|
||||
export abstract class GraphRenderer<T, U> {
|
||||
abstract render(graph: T): void;
|
||||
abstract getNodeById(id: string): U | null;
|
||||
abstract snapToNode(node: U): void;
|
||||
abstract snapToRoot(): void;
|
||||
abstract zoomScale(scale: number): void;
|
||||
abstract root: U | null;
|
||||
abstract get graphElement(): HTMLElement;
|
||||
|
||||
protected nodeClickListeners: ((pointerEvent: PointerEvent, node: U) => void)[] = [];
|
||||
protected nodeMouseoverListeners: ((pointerEvent: PointerEvent, node: U) => void)[] = [];
|
||||
protected nodeMouseoutListeners: ((pointerEvent: PointerEvent, node: U) => void)[] = [];
|
||||
|
||||
cleanup(): void {
|
||||
this.nodeClickListeners = [];
|
||||
this.nodeMouseoverListeners = [];
|
||||
this.nodeMouseoutListeners = [];
|
||||
}
|
||||
|
||||
onNodeClick(cb: (pointerEvent: PointerEvent, node: U) => void): void {
|
||||
this.nodeClickListeners.push(cb);
|
||||
}
|
||||
|
||||
onNodeMouseover(cb: (pointerEvent: PointerEvent, node: U) => void): void {
|
||||
this.nodeMouseoverListeners.push(cb);
|
||||
}
|
||||
|
||||
onNodeMouseout(cb: (pointerEvent: PointerEvent, node: U) => void): void {
|
||||
this.nodeMouseoutListeners.push(cb);
|
||||
}
|
||||
}
|
||||
|
||||
interface InjectorTreeVisualizerConfig {
|
||||
/** WARNING: For vertically-oriented trees, use separation greater than `1` */
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
nodeSize: [width: number, height: number];
|
||||
nodeSeparation: (nodeA: InjectorTreeD3Node, nodeB: InjectorTreeD3Node) => number;
|
||||
nodeLabelSize: [width: number, height: number];
|
||||
}
|
||||
|
||||
export class InjectorTreeVisualizer extends GraphRenderer<InjectorTreeNode, InjectorTreeD3Node> {
|
||||
public config: InjectorTreeVisualizerConfig;
|
||||
|
||||
constructor(
|
||||
private _containerElement: HTMLElement,
|
||||
private _graphElement: HTMLElement,
|
||||
{
|
||||
orientation = 'horizontal',
|
||||
nodeSize = [200, 500],
|
||||
nodeSeparation = () => 2,
|
||||
nodeLabelSize = [250, 60],
|
||||
}: Partial<InjectorTreeVisualizerConfig> = {},
|
||||
) {
|
||||
super();
|
||||
|
||||
this.config = {
|
||||
orientation,
|
||||
nodeSize,
|
||||
nodeSeparation,
|
||||
nodeLabelSize,
|
||||
};
|
||||
}
|
||||
|
||||
private d3 = d3;
|
||||
|
||||
override root: InjectorTreeD3Node | null = null;
|
||||
zoomController: d3.ZoomBehavior<HTMLElement, unknown> | null = null;
|
||||
|
||||
override zoomScale(scale: number) {
|
||||
if (this.zoomController) {
|
||||
this.zoomController.scaleTo(
|
||||
this.d3.select<HTMLElement, unknown>(this._containerElement),
|
||||
scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
override snapToRoot(scale = 1): void {
|
||||
if (this.root) {
|
||||
this.snapToNode(this.root, scale);
|
||||
}
|
||||
}
|
||||
|
||||
override snapToNode(node: InjectorTreeD3Node, scale = 1): void {
|
||||
const svg = this.d3.select(this._containerElement);
|
||||
const contHalfWidth = this._containerElement.clientWidth / 2;
|
||||
const contHalfHeight = this._containerElement.clientHeight / 2;
|
||||
const {x, y} = this.getNodeCoor(node);
|
||||
|
||||
const t = d3.zoomIdentity
|
||||
.translate(contHalfWidth, contHalfHeight)
|
||||
.scale(scale)
|
||||
.translate(-x, -y);
|
||||
svg.transition().duration(500).call(this.zoomController!.transform, t);
|
||||
}
|
||||
|
||||
override get graphElement(): HTMLElement {
|
||||
return this._graphElement;
|
||||
}
|
||||
|
||||
override getNodeById(id: string): InjectorTreeD3Node | null {
|
||||
const selection = this.d3
|
||||
.select<HTMLElement, InjectorTreeD3Node>(this._containerElement)
|
||||
.select(`.node[data-id="${id}"]`);
|
||||
if (selection.empty()) {
|
||||
return null;
|
||||
}
|
||||
return selection.datum();
|
||||
}
|
||||
|
||||
override cleanup(): void {
|
||||
super.cleanup();
|
||||
this.d3.select(this._graphElement).selectAll('*').remove();
|
||||
}
|
||||
|
||||
override render(injectorGraph: InjectorTreeNode): void {
|
||||
// cleanup old graph
|
||||
this.cleanup();
|
||||
|
||||
const data = this.d3.hierarchy(injectorGraph, (node: InjectorTreeNode) => node.children);
|
||||
const tree = this.d3.tree<InjectorTreeNode>();
|
||||
const svg = this.d3.select(this._containerElement);
|
||||
const g = this.d3.select<HTMLElement, InjectorTreeD3Node>(this._graphElement);
|
||||
|
||||
this.zoomController = this.d3.zoom<HTMLElement, unknown>().scaleExtent([0.1, 2]);
|
||||
this.zoomController.on('start zoom end', (e: {transform: number}) => {
|
||||
g.attr('transform', e.transform);
|
||||
});
|
||||
svg.call(this.zoomController);
|
||||
|
||||
// Compute the new tree layout.
|
||||
tree.nodeSize(this.config.nodeSize);
|
||||
tree.separation((a: InjectorTreeD3Node, b: InjectorTreeD3Node) => {
|
||||
return this.config.nodeSeparation(a, b);
|
||||
});
|
||||
|
||||
const nodes = tree(data);
|
||||
this.root = nodes;
|
||||
|
||||
arrowDefId++;
|
||||
svg
|
||||
.append('svg:defs')
|
||||
.selectAll('marker')
|
||||
.data([`end${arrowDefId}`]) // Different link/path types can be defined here
|
||||
.enter()
|
||||
.append('svg:marker') // This section adds in the arrows
|
||||
.attr('id', String)
|
||||
.attr('viewBox', '0 -5 10 10')
|
||||
.attr('refX', 10)
|
||||
.attr('refY', 0)
|
||||
.attr('class', 'arrow')
|
||||
.attr('markerWidth', 6)
|
||||
.attr('markerHeight', 6)
|
||||
.attr('orient', 'auto')
|
||||
.append('svg:path')
|
||||
.attr('d', 'M0,-5L10,0L0,5');
|
||||
|
||||
const [labelWidth, labelHeight] = this.config.nodeLabelSize;
|
||||
const halfLabelWidth = labelWidth / 2;
|
||||
const halfLabelHeight = labelHeight / 2;
|
||||
|
||||
g.selectAll('.link')
|
||||
.data(nodes.descendants().slice(1))
|
||||
.enter()
|
||||
.append('path')
|
||||
.attr('class', (node: InjectorTreeD3Node) => {
|
||||
const parentId = node.parent?.data?.injector?.id;
|
||||
if (parentId === 'N/A') {
|
||||
return 'link-hidden';
|
||||
}
|
||||
|
||||
return `link`;
|
||||
})
|
||||
.attr('data-id', (node: InjectorTreeD3Node) => {
|
||||
const from = node.data.injector.id;
|
||||
const to = node.parent?.data?.injector?.id;
|
||||
|
||||
if (from && to) {
|
||||
return `${from}-to-${to}`;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.attr('marker-end', `url(#end${arrowDefId})`)
|
||||
.attr('d', (node: InjectorTreeD3Node) => {
|
||||
const {x, y} = this.getNodeCoor(node);
|
||||
const {x: parentX, y: parentY} = this.getNodeCoor(node.parent!);
|
||||
|
||||
if (this.config.orientation === 'horizontal') {
|
||||
return `
|
||||
M${x - halfLabelWidth},${y}
|
||||
C${(x + parentX) / 2},
|
||||
${y} ${(x + parentX) / 2},
|
||||
${parentY} ${parentX + halfLabelWidth},
|
||||
${parentY}`;
|
||||
}
|
||||
|
||||
return `
|
||||
M${x},${y - halfLabelHeight}
|
||||
C${x},
|
||||
${(y + parentY) / 2} ${parentX},
|
||||
${(y + parentY) / 2} ${parentX},
|
||||
${parentY + halfLabelHeight}`;
|
||||
});
|
||||
|
||||
// Declare the nodes
|
||||
const node = g
|
||||
.selectAll('g.node')
|
||||
.data(nodes.descendants())
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', (node: InjectorTreeD3Node) => {
|
||||
if (node.data.injector.id === 'N/A') {
|
||||
return 'node-hidden';
|
||||
}
|
||||
return `node`;
|
||||
})
|
||||
.attr('data-component-id', (node: InjectorTreeD3Node) => {
|
||||
const injector = node.data.injector;
|
||||
if (injector.type === 'element') {
|
||||
return injector.node?.component?.id ?? -1;
|
||||
}
|
||||
return -1;
|
||||
})
|
||||
.attr('data-id', (node: InjectorTreeD3Node) => {
|
||||
return node.data.injector.id;
|
||||
})
|
||||
.on('click', (pointerEvent: PointerEvent, node: InjectorTreeD3Node) => {
|
||||
this.nodeClickListeners.forEach((listener) => listener(pointerEvent, node));
|
||||
})
|
||||
.on('mouseover', (pointerEvent: PointerEvent, node: InjectorTreeD3Node) => {
|
||||
this.nodeMouseoverListeners.forEach((listener) => listener(pointerEvent, node));
|
||||
})
|
||||
.on('mouseout', (pointerEvent: PointerEvent, node: InjectorTreeD3Node) => {
|
||||
this.nodeMouseoutListeners.forEach((listener) => listener(pointerEvent, node));
|
||||
})
|
||||
.attr('transform', (node: InjectorTreeD3Node) => {
|
||||
const {x, y} = this.getNodeCoor(node);
|
||||
return `translate(${x},${y})`;
|
||||
});
|
||||
|
||||
node
|
||||
.append('foreignObject')
|
||||
.attr('width', labelWidth)
|
||||
.attr('height', labelHeight)
|
||||
.attr('x', -halfLabelWidth)
|
||||
.attr('y', -halfLabelHeight)
|
||||
.append('xhtml:div')
|
||||
.attr('title', (node: InjectorTreeD3Node) => {
|
||||
return node.data.injector.name;
|
||||
})
|
||||
.attr('class', (node: InjectorTreeD3Node) => {
|
||||
return [injectorTypeToClassMap.get(node.data?.injector?.type) ?? '', 'node-container'].join(
|
||||
' ',
|
||||
);
|
||||
})
|
||||
.html((node: InjectorTreeD3Node) => {
|
||||
const label = node.data.injector.name;
|
||||
const lengthLimit = 25;
|
||||
return label.length > lengthLimit
|
||||
? label.slice(0, lengthLimit - '...'.length) + '...'
|
||||
: label;
|
||||
});
|
||||
|
||||
svg.attr('height', '100%').attr('width', '100%');
|
||||
}
|
||||
|
||||
/** Returns the node coordinates based on orientation. */
|
||||
private getNodeCoor(node: InjectorTreeD3Node): {x: number; y: number} {
|
||||
const {x, y} = node;
|
||||
|
||||
if (this.config.orientation === 'horizontal') {
|
||||
return {
|
||||
x: y,
|
||||
y: x,
|
||||
};
|
||||
}
|
||||
return {x, y};
|
||||
}
|
||||
}
|
||||
|
|
@ -63,10 +63,9 @@ ng_project(
|
|||
"//:node_modules/rxjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/application-environment:application-environment_rjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/application-services:frame_manager_rjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/dependency-injection:injector_tree_visualizer_rjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/dependency-injection/resolution-path:resolution-path_rjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest/index-forest:index-forest_rjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-resolver:property-resolver_rjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/resolution-path:resolution-path_rjs",
|
||||
"//devtools/projects/protocol:protocol_rjs",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import {Component, input} from '@angular/core';
|
||||
import {SerializedInjectedService} from '../../../../../../../protocol';
|
||||
import {ResolutionPathComponent} from '../../../dependency-injection/resolution-path/resolution-path.component';
|
||||
import {ResolutionPathComponent} from './resolution-path/resolution-path.component';
|
||||
import {MatTooltip} from '@angular/material/tooltip';
|
||||
import {MatExpansionModule} from '@angular/material/expansion';
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ load("//devtools/tools:ng_project.bzl", "ng_project")
|
|||
load("//devtools/tools:typescript.bzl", "ts_test_library")
|
||||
load("//tools:defaults2.bzl", "ng_web_test_suite", "sass_binary")
|
||||
|
||||
package(default_visibility = ["//:__subpackages__"])
|
||||
package(default_visibility = ["//devtools:__subpackages__"])
|
||||
|
||||
sass_binary(
|
||||
name = "resolution_path_styles",
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
@use '../../../../styles/typography';
|
||||
@use '../../../../../../styles/typography';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
|
|
@ -10,7 +10,7 @@ import {ComponentFixture, TestBed} from '@angular/core/testing';
|
|||
import {By} from '@angular/platform-browser';
|
||||
|
||||
import {NODE_TYPE_CLASS_MAP, ResolutionPathComponent} from './resolution-path.component';
|
||||
import {SerializedInjector} from '../../../../../../protocol';
|
||||
import {SerializedInjector} from '../../../../../../../../protocol';
|
||||
|
||||
describe('ResolutionPath', () => {
|
||||
let component: ResolutionPathComponent;
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
import {Component, computed, input} from '@angular/core';
|
||||
import {SerializedInjector} from '../../../../../../protocol';
|
||||
import {SerializedInjector} from '../../../../../../../../protocol';
|
||||
|
||||
export const NODE_TYPE_CLASS_MAP: {[key in SerializedInjector['type']]: string} = {
|
||||
'element': 'type-element',
|
||||
|
|
@ -32,11 +32,9 @@ ng_project(
|
|||
"//:node_modules/@types/d3",
|
||||
"//:node_modules/d3",
|
||||
"//:node_modules/rxjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/dependency-injection:injector_tree_visualizer_rjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/dependency-injection/resolution-path:resolution-path_rjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/injector-tree/injector-providers:injector-providers_rjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/tree-visualizer-host:tree-visualizer-host_rjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/shared/responsive-split:responsive-split_rjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/shared/tree-visualizer-host:tree-visualizer-host_rjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/vendor/angular-split:angular-split_rjs",
|
||||
"//devtools/projects/protocol:protocol_rjs",
|
||||
],
|
||||
|
|
@ -56,7 +54,6 @@ ts_test_library(
|
|||
],
|
||||
deps = [
|
||||
":injector_tree_fns_rjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/dependency-injection:injector_tree_visualizer_rjs",
|
||||
"//devtools/projects/protocol:protocol_rjs",
|
||||
],
|
||||
)
|
||||
|
|
@ -65,7 +62,7 @@ ts_project(
|
|||
name = "injector_tree_fns",
|
||||
srcs = ["injector-tree-fns.ts"],
|
||||
deps = [
|
||||
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/dependency-injection:injector_tree_visualizer_rjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/shared/tree-visualizer-host:tree-visualizer-host_rjs",
|
||||
"//devtools/projects/protocol:protocol_rjs",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ ng_project(
|
|||
],
|
||||
deps = [
|
||||
"//:node_modules/@angular/material",
|
||||
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/dependency-injection/resolution-path:resolution-path_rjs",
|
||||
"//devtools/projects/protocol:protocol_rjs",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -7,17 +7,95 @@
|
|||
*/
|
||||
|
||||
import {DevToolsNode, SerializedInjector} from '../../../../../protocol';
|
||||
|
||||
import {
|
||||
InjectorTreeD3Node,
|
||||
InjectorTreeNode,
|
||||
} from '../dependency-injection/injector-tree-visualizer';
|
||||
SvgD3Link,
|
||||
SvgD3Node,
|
||||
TreeD3Node,
|
||||
TreeNode,
|
||||
TreeVisualizer,
|
||||
} from '../../shared/tree-visualizer-host/tree-visualizer';
|
||||
|
||||
// Types
|
||||
|
||||
export interface InjectorPath {
|
||||
node: DevToolsNode;
|
||||
path: SerializedInjector[];
|
||||
}
|
||||
|
||||
export type InjectorTreeVisualizer = TreeVisualizer<InjectorTreeNode>;
|
||||
|
||||
export interface InjectorTreeNode extends TreeNode {
|
||||
injector: SerializedInjector;
|
||||
children: InjectorTreeNode[];
|
||||
}
|
||||
|
||||
export type InjectorTreeD3Node = TreeD3Node<InjectorTreeNode>;
|
||||
|
||||
// Consts
|
||||
|
||||
const ANGULAR_DIRECTIVES = [
|
||||
'NgClass',
|
||||
'NgComponentOutlet',
|
||||
'NgFor',
|
||||
'NgForOf',
|
||||
'NgIf',
|
||||
'NgOptimizedImage',
|
||||
'NgPlural',
|
||||
'NgPluralCase',
|
||||
'NgStyle',
|
||||
'NgSwitch',
|
||||
'NgSwitchCase',
|
||||
'NgSwitchDefault',
|
||||
'NgTemplateOutlet',
|
||||
'AbstractFormGroupDirective',
|
||||
'CheckboxControlValueAccessor',
|
||||
'CheckboxRequiredValidator',
|
||||
'DefaultValueAccessor',
|
||||
'EmailValidator',
|
||||
'FormArrayName',
|
||||
'FormControlDirective',
|
||||
'FormControlName',
|
||||
'FormGroupDirective',
|
||||
'FormGroupName',
|
||||
'MaxLengthValidator',
|
||||
'MaxValidator',
|
||||
'MinLengthValidator',
|
||||
'MinValidator',
|
||||
'NgControlStatus',
|
||||
'NgControlStatusGroup',
|
||||
'NgForm',
|
||||
'NgModel',
|
||||
'NgModelGroup',
|
||||
'NgSelectOption',
|
||||
'NumberValueAccessor',
|
||||
'PatternValidator',
|
||||
'RadioControlValueAccessor',
|
||||
'RangeValueAccessor',
|
||||
'RequiredValidator',
|
||||
'SelectControlValueAccessor',
|
||||
'SelectMultipleControlValueAccessor',
|
||||
'RouterLink',
|
||||
'RouterLinkActive',
|
||||
'RouterLinkWithHref',
|
||||
'RouterOutlet',
|
||||
'UpgradeComponent',
|
||||
];
|
||||
|
||||
const IGNORED_ANGULAR_INJECTORS = new Set([
|
||||
'Null Injector',
|
||||
...ANGULAR_DIRECTIVES,
|
||||
...ANGULAR_DIRECTIVES.map((directive) => `_${directive}`),
|
||||
]);
|
||||
|
||||
const INJECTOR_TYPE_CLASS_MAP = new Map<SerializedInjector['type'], string>([
|
||||
['imported-module', 'node-imported-module'],
|
||||
['environment', 'node-environment'],
|
||||
['element', 'node-element'],
|
||||
['null', 'node-null'],
|
||||
]);
|
||||
|
||||
// Functions
|
||||
|
||||
export function getInjectorIdsToRootFromNode(node: InjectorTreeD3Node): string[] {
|
||||
const ids: string[] = [];
|
||||
let currentNode = node;
|
||||
|
|
@ -71,8 +149,9 @@ export function transformInjectorResolutionPathsIntoTree(
|
|||
continue;
|
||||
}
|
||||
|
||||
const next = {
|
||||
injector: injector,
|
||||
const next: InjectorTreeNode = {
|
||||
label: injector.name || '',
|
||||
injector,
|
||||
children: [],
|
||||
};
|
||||
next.injector.node = injectorIdToNode.get(next.injector.id);
|
||||
|
|
@ -81,12 +160,11 @@ export function transformInjectorResolutionPathsIntoTree(
|
|||
}
|
||||
}
|
||||
|
||||
const hiddenRoot = {
|
||||
return {
|
||||
label: '',
|
||||
injector: {name: '', type: 'hidden', id: 'N/A'},
|
||||
children: injectorTree,
|
||||
};
|
||||
|
||||
return hiddenRoot as any;
|
||||
}
|
||||
|
||||
export function grabInjectorPathsFromDirectiveForest(
|
||||
|
|
@ -160,60 +238,6 @@ export function splitInjectorPathsIntoElementAndEnvironmentPaths(injectorPaths:
|
|||
};
|
||||
}
|
||||
|
||||
const ANGULAR_DIRECTIVES = [
|
||||
'NgClass',
|
||||
'NgComponentOutlet',
|
||||
'NgFor',
|
||||
'NgForOf',
|
||||
'NgIf',
|
||||
'NgOptimizedImage',
|
||||
'NgPlural',
|
||||
'NgPluralCase',
|
||||
'NgStyle',
|
||||
'NgSwitch',
|
||||
'NgSwitchCase',
|
||||
'NgSwitchDefault',
|
||||
'NgTemplateOutlet',
|
||||
'AbstractFormGroupDirective',
|
||||
'CheckboxControlValueAccessor',
|
||||
'CheckboxRequiredValidator',
|
||||
'DefaultValueAccessor',
|
||||
'EmailValidator',
|
||||
'FormArrayName',
|
||||
'FormControlDirective',
|
||||
'FormControlName',
|
||||
'FormGroupDirective',
|
||||
'FormGroupName',
|
||||
'MaxLengthValidator',
|
||||
'MaxValidator',
|
||||
'MinLengthValidator',
|
||||
'MinValidator',
|
||||
'NgControlStatus',
|
||||
'NgControlStatusGroup',
|
||||
'NgForm',
|
||||
'NgModel',
|
||||
'NgModelGroup',
|
||||
'NgSelectOption',
|
||||
'NumberValueAccessor',
|
||||
'PatternValidator',
|
||||
'RadioControlValueAccessor',
|
||||
'RangeValueAccessor',
|
||||
'RequiredValidator',
|
||||
'SelectControlValueAccessor',
|
||||
'SelectMultipleControlValueAccessor',
|
||||
'RouterLink',
|
||||
'RouterLinkActive',
|
||||
'RouterLinkWithHref',
|
||||
'RouterOutlet',
|
||||
'UpgradeComponent',
|
||||
];
|
||||
|
||||
const ignoredAngularInjectors = new Set([
|
||||
'Null Injector',
|
||||
...ANGULAR_DIRECTIVES,
|
||||
...ANGULAR_DIRECTIVES.map((directive) => `_${directive}`),
|
||||
]);
|
||||
|
||||
export function filterOutInjectorsWithNoProviders(injectorPaths: InjectorPath[]): InjectorPath[] {
|
||||
for (const injectorPath of injectorPaths) {
|
||||
injectorPath.path = injectorPath.path.filter(
|
||||
|
|
@ -226,6 +250,43 @@ export function filterOutInjectorsWithNoProviders(injectorPaths: InjectorPath[])
|
|||
|
||||
export function filterOutAngularInjectors(injectorPaths: InjectorPath[]): InjectorPath[] {
|
||||
return injectorPaths.map(({node, path}) => {
|
||||
return {node, path: path.filter((injector) => !ignoredAngularInjectors.has(injector.name))};
|
||||
return {node, path: path.filter((injector) => !IGNORED_ANGULAR_INJECTORS.has(injector.name))};
|
||||
});
|
||||
}
|
||||
|
||||
export function d3InjectorTreeLinkModifier(link: SvgD3Link<InjectorTreeNode>) {
|
||||
link
|
||||
.attr('hidden', (node: InjectorTreeD3Node) => {
|
||||
const parentId = node.parent?.data.injector.id;
|
||||
return parentId === 'N/A' ? 'true' : null;
|
||||
})
|
||||
.attr('data-id', (node: InjectorTreeD3Node) => {
|
||||
const from = node.data.injector.id;
|
||||
const to = node.parent?.data.injector.id;
|
||||
|
||||
if (from && to) {
|
||||
return `${from}-to-${to}`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
export function d3InjectorTreeNodeModifier(d3Node: SvgD3Node<InjectorTreeNode>) {
|
||||
d3Node
|
||||
.attr('class', (node: InjectorTreeD3Node) => {
|
||||
return [d3Node.attr('class'), INJECTOR_TYPE_CLASS_MAP.get(node.data.injector.type) ?? '']
|
||||
.filter((c) => !!c)
|
||||
.join(' ');
|
||||
})
|
||||
.attr('hidden', (node: InjectorTreeD3Node) => (node.data.injector.id === 'N/A' ? 'true' : null))
|
||||
.attr('data-id', (node: InjectorTreeD3Node) => {
|
||||
return node.data.injector.id;
|
||||
})
|
||||
.attr('data-component-id', (node: InjectorTreeD3Node) => {
|
||||
if (node.data.injector.type === 'element') {
|
||||
const injector = node.data.injector;
|
||||
return injector.node?.component?.id ?? -1;
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,18 +31,19 @@ import {
|
|||
} from '../../../../../protocol';
|
||||
|
||||
import {SplitAreaDirective, SplitComponent} from '../../vendor/angular-split/public_api';
|
||||
import {
|
||||
InjectorTreeD3Node,
|
||||
InjectorTreeVisualizer,
|
||||
} from '../dependency-injection/injector-tree-visualizer';
|
||||
import {TreeVisualizerHostComponent} from '../tree-visualizer-host/tree-visualizer-host.component';
|
||||
import {TreeVisualizer} from '../../shared/tree-visualizer-host/tree-visualizer';
|
||||
import {TreeVisualizerHostComponent} from '../../shared/tree-visualizer-host/tree-visualizer-host.component';
|
||||
import {InjectorProvidersComponent} from './injector-providers/injector-providers.component';
|
||||
import {
|
||||
d3InjectorTreeLinkModifier,
|
||||
d3InjectorTreeNodeModifier,
|
||||
filterOutAngularInjectors,
|
||||
filterOutInjectorsWithNoProviders,
|
||||
generateEdgeIdsFromNodeIds,
|
||||
getInjectorIdsToRootFromNode,
|
||||
grabInjectorPathsFromDirectiveForest,
|
||||
InjectorTreeD3Node,
|
||||
InjectorTreeVisualizer,
|
||||
splitInjectorPathsIntoElementAndEnvironmentPaths,
|
||||
transformInjectorResolutionPathsIntoTree,
|
||||
} from './injector-tree-fns';
|
||||
|
|
@ -305,7 +306,10 @@ export class InjectorTreeComponent {
|
|||
const g = environmentTree.group().nativeElement;
|
||||
|
||||
this.injectorTreeGraph?.cleanup?.();
|
||||
this.injectorTreeGraph = new InjectorTreeVisualizer(svg, g);
|
||||
this.injectorTreeGraph = new TreeVisualizer(svg, g, {
|
||||
d3NodeModifier: d3InjectorTreeNodeModifier,
|
||||
d3LinkModifier: d3InjectorTreeLinkModifier,
|
||||
});
|
||||
}
|
||||
|
||||
setUpElementInjectorVisualizer(): void {
|
||||
|
|
@ -318,7 +322,11 @@ export class InjectorTreeComponent {
|
|||
const g = elementTree.group().nativeElement;
|
||||
|
||||
this.elementInjectorTreeGraph?.cleanup?.();
|
||||
this.elementInjectorTreeGraph = new InjectorTreeVisualizer(svg, g, {nodeSeparation: () => 1});
|
||||
this.elementInjectorTreeGraph = new TreeVisualizer(svg, g, {
|
||||
nodeSeparation: () => 1,
|
||||
d3NodeModifier: d3InjectorTreeNodeModifier,
|
||||
d3LinkModifier: d3InjectorTreeLinkModifier,
|
||||
});
|
||||
}
|
||||
|
||||
highlightPathFromSelectedInjector(): void {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ ng_project(
|
|||
srcs = [
|
||||
"route-details-row.component.ts",
|
||||
"router-tree.component.ts",
|
||||
"router-tree-visualizer.ts",
|
||||
"router-tree-fns.ts",
|
||||
],
|
||||
angular_assets = [
|
||||
":router-tree.component.html",
|
||||
|
|
@ -41,7 +41,7 @@ ng_project(
|
|||
"//:node_modules/rxjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/application-operations:application-operations_rjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/application-services:frame_manager_rjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/tree-visualizer-host:tree-visualizer-host_rjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/shared/tree-visualizer-host:tree-visualizer-host_rjs",
|
||||
"//devtools/projects/ng-devtools/src/lib/vendor/angular-split:angular-split_rjs",
|
||||
"//devtools/projects/protocol:protocol_rjs",
|
||||
],
|
||||
|
|
@ -52,6 +52,7 @@ ts_test_library(
|
|||
srcs = [
|
||||
"route-details-row.component.spec.ts",
|
||||
"router-tree.component.spec.ts",
|
||||
"router-tree-fns.spec.ts",
|
||||
],
|
||||
interop_deps = [
|
||||
":router-tree",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {Route} from '../../../../../protocol';
|
||||
import {
|
||||
getRouteLabel,
|
||||
mapRoute,
|
||||
RouterTreeNode,
|
||||
transformRoutesIntoVisTree,
|
||||
} from './router-tree-fns';
|
||||
|
||||
describe('router-tree-fns', () => {
|
||||
describe('getRouteLabel', () => {
|
||||
it('should return route label', () => {
|
||||
const route = {
|
||||
path: '/foo/bar',
|
||||
} as Route;
|
||||
const parent = {
|
||||
path: '/foo',
|
||||
} as Route;
|
||||
|
||||
expect(getRouteLabel(route, parent, false)).toEqual('/bar');
|
||||
});
|
||||
|
||||
it('should return full route label', () => {
|
||||
const route = {
|
||||
path: '/foo/bar',
|
||||
} as Route;
|
||||
const parent = {
|
||||
path: '/foo',
|
||||
} as Route;
|
||||
|
||||
expect(getRouteLabel(route, parent, true)).toEqual('/foo/bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapRoute', () => {
|
||||
it('should map route', () => {
|
||||
const route = {
|
||||
isActive: true,
|
||||
path: '/foo/bar',
|
||||
} as Route;
|
||||
const parent = {
|
||||
path: '/foo',
|
||||
} as Route;
|
||||
|
||||
const treeNode = mapRoute(route, parent, false);
|
||||
expect(treeNode).toEqual({
|
||||
isActive: true,
|
||||
path: '/foo/bar',
|
||||
label: '/bar',
|
||||
children: [] as RouterTreeNode[],
|
||||
} as RouterTreeNode);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformRoutesIntoVisTree', () => {
|
||||
it('should transform routes to visualizer tree', () => {
|
||||
const rootRoute = {
|
||||
path: '',
|
||||
children: [
|
||||
{
|
||||
path: '/home',
|
||||
},
|
||||
{
|
||||
path: '/list',
|
||||
children: [{path: '/list/foo'}, {path: '/list/bar'}],
|
||||
},
|
||||
],
|
||||
} as Route;
|
||||
|
||||
const rootNode = transformRoutesIntoVisTree(rootRoute, false);
|
||||
|
||||
expect(rootNode).toEqual({
|
||||
label: '',
|
||||
path: '',
|
||||
children: [
|
||||
{
|
||||
path: '/home',
|
||||
label: '/home',
|
||||
children: [] as RouterTreeNode[],
|
||||
},
|
||||
{
|
||||
path: '/list',
|
||||
label: '/list',
|
||||
children: [
|
||||
{path: '/list/foo', label: '/foo', children: []},
|
||||
{path: '/list/bar', label: '/bar', children: []},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as RouterTreeNode);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {
|
||||
TreeD3Node,
|
||||
TreeNode,
|
||||
TreeVisualizer,
|
||||
} from '../../shared/tree-visualizer-host/tree-visualizer';
|
||||
import {Route} from '../../../../../protocol';
|
||||
|
||||
export interface RouterTreeNode extends TreeNode, Route {
|
||||
children: RouterTreeNode[];
|
||||
}
|
||||
|
||||
export type RouterTreeVisualizer = TreeVisualizer<RouterTreeNode>;
|
||||
export type RouterTreeD3Node = TreeD3Node<RouterTreeNode>;
|
||||
|
||||
export function getRouteLabel(
|
||||
route: Route | RouterTreeNode,
|
||||
parent: Route | RouterTreeNode | undefined,
|
||||
showFullPath: boolean,
|
||||
): string {
|
||||
return (showFullPath ? route.path : route.path.replace(parent?.path || '', '')) || '';
|
||||
}
|
||||
|
||||
export function mapRoute(
|
||||
route: Route,
|
||||
parent: Route | undefined,
|
||||
showFullPath: boolean,
|
||||
): RouterTreeNode {
|
||||
return {
|
||||
...route,
|
||||
label: getRouteLabel(route, parent, showFullPath),
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function transformRoutesIntoVisTree(root: Route, showFullPath: boolean): RouterTreeNode {
|
||||
let rootNode: RouterTreeNode | undefined;
|
||||
const routesQueue: {route: Route; parent?: RouterTreeNode}[] = [{route: root}];
|
||||
|
||||
while (routesQueue.length) {
|
||||
const {route, parent} = routesQueue.shift()!;
|
||||
const routeNode = mapRoute(route, parent, showFullPath);
|
||||
|
||||
if (!rootNode) {
|
||||
rootNode = routeNode;
|
||||
}
|
||||
|
||||
if (parent) {
|
||||
parent.children.push(routeNode);
|
||||
}
|
||||
|
||||
if (route.children) {
|
||||
for (const child of route.children) {
|
||||
routesQueue.push({route: child, parent: routeNode});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rootNode!;
|
||||
}
|
||||
|
|
@ -1,305 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import * as d3 from 'd3';
|
||||
import {Route} from '../../../../../protocol';
|
||||
|
||||
let arrowDefId = 0;
|
||||
|
||||
export type RouterTreeD3Node = d3.HierarchyPointNode<Route>;
|
||||
|
||||
interface RouterTreeVisualizerConfig {
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
nodeSize: [width: number, height: number];
|
||||
nodeSeparation: (nodeA: RouterTreeD3Node, nodeB: RouterTreeD3Node) => number;
|
||||
nodeLabelSize: [width: number, height: number];
|
||||
}
|
||||
|
||||
export class RouterTreeVisualizer {
|
||||
private readonly config: RouterTreeVisualizerConfig;
|
||||
private d3 = d3;
|
||||
private root: RouterTreeD3Node | null = null;
|
||||
private zoomController: d3.ZoomBehavior<HTMLElement, unknown> | null = null;
|
||||
|
||||
protected nodeClickListeners: ((pointerEvent: PointerEvent, node: RouterTreeD3Node) => void)[] =
|
||||
[];
|
||||
protected nodeMouseoverListeners: ((
|
||||
pointerEvent: PointerEvent,
|
||||
node: RouterTreeD3Node,
|
||||
) => void)[] = [];
|
||||
protected nodeMouseoutListeners: ((
|
||||
pointerEvent: PointerEvent,
|
||||
node: RouterTreeD3Node,
|
||||
) => void)[] = [];
|
||||
|
||||
constructor(
|
||||
private _containerElement: HTMLElement,
|
||||
private _graphElement: HTMLElement,
|
||||
{
|
||||
orientation = 'horizontal',
|
||||
nodeSize = [200, 500],
|
||||
nodeSeparation = () => 2.5,
|
||||
nodeLabelSize = [300, 60],
|
||||
}: Partial<RouterTreeVisualizerConfig> = {},
|
||||
) {
|
||||
this.config = {
|
||||
orientation,
|
||||
nodeSize,
|
||||
nodeSeparation,
|
||||
nodeLabelSize,
|
||||
};
|
||||
}
|
||||
|
||||
onNodeClick(cb: (pointerEvent: PointerEvent, node: RouterTreeD3Node) => void): void {
|
||||
this.nodeClickListeners.push(cb);
|
||||
}
|
||||
|
||||
onNodeMouseover(cb: (pointerEvent: PointerEvent, node: RouterTreeD3Node) => void): void {
|
||||
this.nodeMouseoverListeners.push(cb);
|
||||
}
|
||||
|
||||
onNodeMouseout(cb: (pointerEvent: PointerEvent, node: RouterTreeD3Node) => void): void {
|
||||
this.nodeMouseoutListeners.push(cb);
|
||||
}
|
||||
|
||||
private zoomScale(scale: number) {
|
||||
if (this.zoomController) {
|
||||
this.zoomController.scaleTo(
|
||||
this.d3.select<HTMLElement, unknown>(this._containerElement),
|
||||
scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
snapToRoot(scale = 1): void {
|
||||
if (this.root) {
|
||||
this.snapToNode(this.root, scale);
|
||||
}
|
||||
}
|
||||
|
||||
snapToNode(node: RouterTreeD3Node, scale = 1): void {
|
||||
const svg = this.d3.select(this._containerElement);
|
||||
const halfHeight = this._containerElement.clientHeight / 2;
|
||||
const t = d3.zoomIdentity.translate(250, halfHeight - node.x).scale(scale);
|
||||
svg.transition().duration(500).call(this.zoomController!.transform, t);
|
||||
}
|
||||
|
||||
get graphElement(): HTMLElement {
|
||||
return this._graphElement;
|
||||
}
|
||||
|
||||
private getNodeById(id: string): RouterTreeD3Node | null {
|
||||
const selection = this.d3
|
||||
.select<HTMLElement, RouterTreeD3Node>(this._containerElement)
|
||||
.select(`.node[data-id="${id}"]`);
|
||||
if (selection.empty()) {
|
||||
return null;
|
||||
}
|
||||
return selection.datum();
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.d3.select(this._graphElement).selectAll('*').remove();
|
||||
this.nodeClickListeners = [];
|
||||
this.nodeMouseoverListeners = [];
|
||||
this.nodeMouseoutListeners = [];
|
||||
}
|
||||
|
||||
render(route: Route, filterRegex: RegExp, showFullPath: boolean): void {
|
||||
// cleanup old graph
|
||||
this.cleanup();
|
||||
|
||||
const data = this.d3.hierarchy(route, (node: Route) => node.children);
|
||||
const tree = this.d3.tree<Route>();
|
||||
const svg = this.d3.select(this._containerElement);
|
||||
const g = this.d3.select<HTMLElement, RouterTreeD3Node>(this._graphElement);
|
||||
|
||||
const size = 20;
|
||||
|
||||
svg.selectAll('text').remove();
|
||||
svg.selectAll('rect').remove();
|
||||
svg.selectAll('defs').remove();
|
||||
|
||||
svg
|
||||
.append('rect')
|
||||
.attr('x', 10)
|
||||
.attr('y', 10)
|
||||
.attr('width', size)
|
||||
.attr('height', size)
|
||||
.style('stroke', 'var(--red-05)')
|
||||
.style('fill', 'var(--red-06)');
|
||||
|
||||
svg
|
||||
.append('rect')
|
||||
.attr('x', 10)
|
||||
.attr('y', 45)
|
||||
.attr('width', size)
|
||||
.attr('height', size)
|
||||
.style('stroke', 'var(--blue-02)')
|
||||
.style('fill', 'var(--blue-03)');
|
||||
|
||||
svg
|
||||
.append('rect')
|
||||
.attr('x', 10)
|
||||
.attr('y', 80)
|
||||
.attr('width', size)
|
||||
.attr('height', size)
|
||||
.style('stroke', 'var(--green-02)')
|
||||
.style('fill', 'var(--green-03)');
|
||||
|
||||
svg
|
||||
.append('text')
|
||||
.attr('x', 37)
|
||||
.attr('y', 21)
|
||||
.attr('class', 'legend-router-tree')
|
||||
.text('Eager loaded routes')
|
||||
.attr('alignment-baseline', 'middle');
|
||||
|
||||
svg
|
||||
.append('text')
|
||||
.attr('x', 37)
|
||||
.attr('y', 56)
|
||||
.attr('class', 'legend-router-tree')
|
||||
.text('Lazy Loaded Route')
|
||||
.attr('alignment-baseline', 'middle');
|
||||
|
||||
svg
|
||||
.append('text')
|
||||
.attr('x', 37)
|
||||
.attr('y', 92)
|
||||
.attr('class', 'legend-router-tree')
|
||||
.text('Active Route')
|
||||
.attr('alignment-baseline', 'middle');
|
||||
|
||||
this.zoomController = this.d3.zoom<HTMLElement, unknown>().scaleExtent([0.1, 2]);
|
||||
this.zoomController.on('start zoom end', (e: {transform: number}) => {
|
||||
g.attr('transform', e.transform);
|
||||
});
|
||||
svg.call(this.zoomController);
|
||||
|
||||
// Compute the new tree layout.
|
||||
tree.nodeSize(this.config.nodeSize);
|
||||
tree.separation((a: RouterTreeD3Node, b: RouterTreeD3Node) => {
|
||||
return this.config.nodeSeparation(a, b);
|
||||
});
|
||||
|
||||
const nodes = tree(data);
|
||||
this.root = nodes;
|
||||
|
||||
arrowDefId++;
|
||||
svg
|
||||
.append('svg:defs')
|
||||
.selectAll('marker')
|
||||
.data([`end${arrowDefId}`]) // Different link/path types can be defined here
|
||||
.enter()
|
||||
.append('svg:marker') // This section adds in the arrows
|
||||
.attr('id', String)
|
||||
.attr('viewBox', '0 -5 10 10')
|
||||
.attr('refX', 15)
|
||||
.attr('refY', 0)
|
||||
.attr('class', 'arrow')
|
||||
.attr('markerWidth', 6)
|
||||
.attr('markerHeight', 6)
|
||||
.attr('orient', 'auto')
|
||||
.append('svg:path')
|
||||
.attr('d', 'M0,-5L10,0L0,5');
|
||||
|
||||
g.selectAll('.link')
|
||||
.data(nodes.descendants().slice(1))
|
||||
.enter()
|
||||
.append('path')
|
||||
.attr('class', (node: RouterTreeD3Node) => {
|
||||
return `link`;
|
||||
})
|
||||
.attr('d', (node: RouterTreeD3Node) => {
|
||||
const parent = node.parent!;
|
||||
if (this.config.orientation === 'horizontal') {
|
||||
return `M${node.y},${node.x},C${(node.y + parent.y) / 2}, ${node.x} ${(node.y + parent.y) / 2},${parent.x} ${parent.y},${parent.x}`;
|
||||
}
|
||||
|
||||
return `M${node.x},${node.y},C${(node.x + parent.x) / 2}, ${node.y} ${(node.x + parent.x) / 2},${parent.y} ${parent.x},${parent.y}`;
|
||||
});
|
||||
|
||||
// Declare the nodes
|
||||
const node = g
|
||||
.selectAll('g.node')
|
||||
.data(nodes.descendants())
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', (node: RouterTreeD3Node) => {
|
||||
return `node`;
|
||||
})
|
||||
.attr('transform', (node: RouterTreeD3Node) => {
|
||||
if (this.config.orientation === 'horizontal') {
|
||||
return `translate(${node.y},${node.x})`;
|
||||
}
|
||||
return `translate(${node.x},${node.y})`;
|
||||
})
|
||||
.on('click', (pointerEvent: PointerEvent, node: RouterTreeD3Node) => {
|
||||
for (const listener of this.nodeClickListeners) {
|
||||
listener(pointerEvent, node);
|
||||
}
|
||||
})
|
||||
.on('mouseover', (pointerEvent: PointerEvent, node: RouterTreeD3Node) => {
|
||||
for (const listener of this.nodeMouseoverListeners) {
|
||||
listener(pointerEvent, node);
|
||||
}
|
||||
})
|
||||
.on('mouseout', (pointerEvent: PointerEvent, node: RouterTreeD3Node) => {
|
||||
for (const listener of this.nodeMouseoutListeners) {
|
||||
listener(pointerEvent, node);
|
||||
}
|
||||
});
|
||||
const [width, height] = this.config.nodeLabelSize!;
|
||||
|
||||
node
|
||||
.append('foreignObject')
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.attr('x', -1 * (width - 10))
|
||||
.attr('y', -1 * (height / 2))
|
||||
.append('xhtml:div')
|
||||
.attr('title', (node: RouterTreeD3Node) => {
|
||||
return node.data.path || '';
|
||||
})
|
||||
.attr('class', (node: RouterTreeD3Node) => {
|
||||
const label =
|
||||
(showFullPath
|
||||
? node.data.path
|
||||
: node.data.path.replace(node.parent?.data.path || '', '')) || '';
|
||||
const isMatched = filterRegex.test(label.toLowerCase());
|
||||
|
||||
const nodeClasses = ['node-container'];
|
||||
if (node.data.isActive) {
|
||||
nodeClasses.push('node-element');
|
||||
} else if (node.data.isLazy) {
|
||||
nodeClasses.push('node-lazy');
|
||||
} else {
|
||||
nodeClasses.push('node-environment');
|
||||
}
|
||||
|
||||
if (isMatched) {
|
||||
nodeClasses.push('node-search');
|
||||
}
|
||||
return nodeClasses.join(' ');
|
||||
})
|
||||
.text((node: RouterTreeD3Node) => {
|
||||
const label =
|
||||
(showFullPath
|
||||
? node.data.path
|
||||
: node.data.path.replace(node.parent?.data.path || '', '')) || '';
|
||||
const lengthLimit = 25;
|
||||
const labelText =
|
||||
label.length > lengthLimit ? label.slice(0, lengthLimit - '...'.length) + '...' : label;
|
||||
|
||||
return labelText;
|
||||
});
|
||||
|
||||
svg.attr('height', '100%').attr('width', '100%');
|
||||
}
|
||||
}
|
||||
|
|
@ -9,9 +9,8 @@
|
|||
import {CommonModule} from '@angular/common';
|
||||
import {afterNextRender, Component, effect, inject, input, signal, viewChild} from '@angular/core';
|
||||
import {MatInputModule} from '@angular/material/input';
|
||||
import {RouterTreeD3Node, RouterTreeVisualizer} from './router-tree-visualizer';
|
||||
import {MatCheckboxModule} from '@angular/material/checkbox';
|
||||
import {TreeVisualizerHostComponent} from '../tree-visualizer-host/tree-visualizer-host.component';
|
||||
import {TreeVisualizerHostComponent} from '../../shared/tree-visualizer-host/tree-visualizer-host.component';
|
||||
import {SplitAreaDirective, SplitComponent} from '../../vendor/angular-split/public_api';
|
||||
import {MatIconModule} from '@angular/material/icon';
|
||||
import {MatButtonModule} from '@angular/material/button';
|
||||
|
|
@ -20,6 +19,14 @@ import {RouteDetailsRowComponent} from './route-details-row.component';
|
|||
import {MatTableModule} from '@angular/material/table';
|
||||
import {FrameManager} from '../../application-services/frame_manager';
|
||||
import {Events, MessageBus, Route} from '../../../../../protocol';
|
||||
import {SvgD3Node, TreeVisualizer} from '../../shared/tree-visualizer-host/tree-visualizer';
|
||||
import {
|
||||
RouterTreeVisualizer,
|
||||
RouterTreeD3Node,
|
||||
transformRoutesIntoVisTree,
|
||||
RouterTreeNode,
|
||||
getRouteLabel,
|
||||
} from './router-tree-fns';
|
||||
|
||||
const DEFAULT_FILTER = /.^/;
|
||||
|
||||
|
|
@ -88,8 +95,9 @@ export class RouterTreeComponent {
|
|||
const group = this.routerTree().group().nativeElement;
|
||||
|
||||
this.routerTreeVisualizer?.cleanup?.();
|
||||
this.routerTreeVisualizer = new RouterTreeVisualizer(container, group, {
|
||||
this.routerTreeVisualizer = new TreeVisualizer(container, group, {
|
||||
nodeSeparation: () => 1,
|
||||
d3NodeModifier: (n) => this.d3NodeModifier(n),
|
||||
});
|
||||
|
||||
this.visualizerReady.set(true);
|
||||
|
|
@ -103,9 +111,13 @@ export class RouterTreeComponent {
|
|||
}
|
||||
|
||||
renderGraph(routes: Route[]): void {
|
||||
this.routerTreeVisualizer?.render(routes[0], this.filterRegex, this.showFullPath);
|
||||
const root = transformRoutesIntoVisTree(routes[0], this.showFullPath);
|
||||
this.routerTreeVisualizer?.render(root);
|
||||
this.routerTreeVisualizer?.onNodeClick((_, node) => {
|
||||
this.selectedRoute.set(node);
|
||||
setTimeout(() => {
|
||||
this.routerTreeVisualizer?.snapToNode(node, 0.7);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -124,4 +136,25 @@ export class RouterTreeComponent {
|
|||
navigateRoute(route: any): void {
|
||||
this.messageBus.emit('navigateRoute', [route.data.path]);
|
||||
}
|
||||
|
||||
private d3NodeModifier(d3Node: SvgD3Node<RouterTreeNode>) {
|
||||
d3Node.attr('class', (node: RouterTreeD3Node) => {
|
||||
const name = getRouteLabel(node.data, node.parent?.data, this.showFullPath);
|
||||
const isMatched = this.filterRegex.test(name.toLowerCase());
|
||||
|
||||
const nodeClasses = [d3Node.attr('class')];
|
||||
if (node.data.isActive) {
|
||||
nodeClasses.push('node-element');
|
||||
} else if (node.data.isLazy) {
|
||||
nodeClasses.push('node-lazy');
|
||||
} else {
|
||||
nodeClasses.push('node-environment');
|
||||
}
|
||||
|
||||
if (isMatched) {
|
||||
nodeClasses.push('node-search');
|
||||
}
|
||||
return nodeClasses.join(' ');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ sass_binary(
|
|||
ng_project(
|
||||
name = "tree-visualizer-host",
|
||||
srcs = [
|
||||
"graph-renderer.ts",
|
||||
"tree-visualizer.ts",
|
||||
"tree-visualizer-host.component.ts",
|
||||
],
|
||||
angular_assets = [
|
||||
|
|
@ -20,4 +22,8 @@ ng_project(
|
|||
interop_deps = [
|
||||
"//packages/core",
|
||||
],
|
||||
deps = [
|
||||
"//:node_modules/@types/d3",
|
||||
"//:node_modules/d3",
|
||||
],
|
||||
)
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
export abstract class GraphRenderer<T, U> {
|
||||
abstract render(graph: T): void;
|
||||
abstract getNodeById(id: string): U | null;
|
||||
abstract snapToNode(node: U): void;
|
||||
abstract snapToRoot(): void;
|
||||
abstract zoomScale(scale: number): void;
|
||||
abstract root: U | null;
|
||||
abstract get graphElement(): HTMLElement;
|
||||
|
||||
protected nodeClickListeners: ((pointerEvent: PointerEvent, node: U) => void)[] = [];
|
||||
protected nodeMouseoverListeners: ((pointerEvent: PointerEvent, node: U) => void)[] = [];
|
||||
protected nodeMouseoutListeners: ((pointerEvent: PointerEvent, node: U) => void)[] = [];
|
||||
|
||||
cleanup(): void {
|
||||
this.nodeClickListeners = [];
|
||||
this.nodeMouseoverListeners = [];
|
||||
this.nodeMouseoutListeners = [];
|
||||
}
|
||||
|
||||
onNodeClick(cb: (pointerEvent: PointerEvent, node: U) => void): void {
|
||||
this.nodeClickListeners.push(cb);
|
||||
}
|
||||
|
||||
onNodeMouseover(cb: (pointerEvent: PointerEvent, node: U) => void): void {
|
||||
this.nodeMouseoverListeners.push(cb);
|
||||
}
|
||||
|
||||
onNodeMouseout(cb: (pointerEvent: PointerEvent, node: U) => void): void {
|
||||
this.nodeMouseoutListeners.push(cb);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,11 +13,6 @@
|
|||
}
|
||||
|
||||
::ng-deep {
|
||||
.node-hidden,
|
||||
.link-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.legend {
|
||||
background: var(--primary-contrast);
|
||||
}
|
||||
|
|
@ -42,54 +37,29 @@
|
|||
|
||||
.node {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--quaternary-contrast);
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: black;
|
||||
font-size: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
font-weight: 300;
|
||||
|
||||
.node-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: black;
|
||||
font-size: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
font-weight: 300;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&.highlighted {
|
||||
.node-container,
|
||||
.node-container:hover {
|
||||
background: var(--blue-02);
|
||||
border-color: white;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
.node-container,
|
||||
.node-container:hover {
|
||||
color: var(--blue-02);
|
||||
background: white;
|
||||
border-width: 3px;
|
||||
border-color: var(--blue-02);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.node-search {
|
||||
&.node-search {
|
||||
border-width: 4px !important;
|
||||
border-style: groove !important;
|
||||
text-decoration: underline;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.node-environment {
|
||||
&.node-environment {
|
||||
border: 1px solid var(--red-05);
|
||||
background: var(--red-06);
|
||||
|
||||
|
|
@ -98,7 +68,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.node-imported-module {
|
||||
&.node-imported-module {
|
||||
border-color: var(--purple-02);
|
||||
background: var(--purple-03);
|
||||
|
||||
|
|
@ -107,7 +77,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.node-lazy {
|
||||
&.node-lazy {
|
||||
border-color: var(--blue-02);
|
||||
background: var(--blue-03);
|
||||
|
||||
|
|
@ -116,7 +86,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.node-element {
|
||||
&.node-element {
|
||||
border-color: var(--green-02);
|
||||
background: var(--green-03);
|
||||
|
||||
|
|
@ -125,7 +95,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.node-null {
|
||||
&.node-null {
|
||||
border: 1px solid var(--quaternary-contrast);
|
||||
background: white;
|
||||
|
||||
|
|
@ -134,10 +104,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
.node-label {
|
||||
color: black;
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
&.highlighted,
|
||||
&.highlighted:hover {
|
||||
background: var(--blue-02);
|
||||
border-color: white;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.selected,
|
||||
&.selected:hover {
|
||||
color: var(--blue-02);
|
||||
background: white;
|
||||
border-width: 3px;
|
||||
border-color: var(--blue-02);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import * as d3 from 'd3';
|
||||
import {GraphRenderer} from './graph-renderer';
|
||||
|
||||
let arrowDefId = 0;
|
||||
|
||||
const MAX_NODE_LABEL_LENGTH = 25;
|
||||
|
||||
export interface TreeNode {
|
||||
label: string;
|
||||
children: TreeNode[];
|
||||
}
|
||||
|
||||
export type TreeD3Node<T extends TreeNode> = d3.HierarchyPointNode<T>;
|
||||
|
||||
export type SvgD3Node<T extends TreeNode> = d3.Selection<
|
||||
d3.BaseType,
|
||||
TreeD3Node<T>,
|
||||
HTMLElement,
|
||||
TreeD3Node<T>
|
||||
>;
|
||||
|
||||
export type SvgD3Link<T extends TreeNode> = d3.Selection<
|
||||
SVGPathElement,
|
||||
d3.HierarchyPointNode<T>,
|
||||
HTMLElement,
|
||||
TreeD3Node<T>
|
||||
>;
|
||||
|
||||
export interface TreeVisualizerConfig<T extends TreeNode> {
|
||||
/** WARNING: For vertically-oriented trees, use separation greater than `1` */
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
nodeSize: [width: number, height: number];
|
||||
nodeSeparation: (nodeA: TreeD3Node<T>, nodeB: TreeD3Node<T>) => number;
|
||||
nodeLabelSize: [width: number, height: number];
|
||||
/** Perform custom changes on the SVG node (e.g. set classes, colors, attributes, etc.) */
|
||||
d3NodeModifier: (node: SvgD3Node<T>) => void;
|
||||
/** Perform custom changes on the SVG link (e.g. set classes, colors, attributes, etc.) */
|
||||
d3LinkModifier: (link: SvgD3Link<T>) => void;
|
||||
}
|
||||
|
||||
export class TreeVisualizer<T extends TreeNode = TreeNode> extends GraphRenderer<T, TreeD3Node<T>> {
|
||||
private zoomController: d3.ZoomBehavior<HTMLElement, unknown> | null = null;
|
||||
private readonly config: TreeVisualizerConfig<T>;
|
||||
private readonly defaultConfig: TreeVisualizerConfig<T> = {
|
||||
orientation: 'horizontal',
|
||||
nodeSize: [200, 500],
|
||||
nodeSeparation: () => 2,
|
||||
nodeLabelSize: [250, 60],
|
||||
d3NodeModifier: () => {},
|
||||
d3LinkModifier: () => {},
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly containerElement: HTMLElement,
|
||||
public readonly graphElement: HTMLElement,
|
||||
config: Partial<TreeVisualizerConfig<T>> = {},
|
||||
) {
|
||||
super();
|
||||
|
||||
this.config = {
|
||||
...this.defaultConfig,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
override root: TreeD3Node<T> | null = null;
|
||||
|
||||
override zoomScale(scale: number) {
|
||||
if (this.zoomController) {
|
||||
this.zoomController.scaleTo(d3.select<HTMLElement, unknown>(this.containerElement), scale);
|
||||
}
|
||||
}
|
||||
|
||||
override snapToRoot(scale = 1): void {
|
||||
if (this.root) {
|
||||
this.snapToNode(this.root, scale);
|
||||
}
|
||||
}
|
||||
|
||||
override snapToNode(node: TreeD3Node<T>, scale = 1): void {
|
||||
const svg = d3.select(this.containerElement);
|
||||
const contHalfWidth = this.containerElement.clientWidth / 2;
|
||||
const contHalfHeight = this.containerElement.clientHeight / 2;
|
||||
const {x, y} = this.getNodeCoor(node);
|
||||
|
||||
const t = d3.zoomIdentity
|
||||
.translate(contHalfWidth, contHalfHeight)
|
||||
.scale(scale)
|
||||
.translate(-x, -y);
|
||||
svg.transition().duration(500).call(this.zoomController!.transform, t);
|
||||
}
|
||||
|
||||
override getNodeById(id: string): TreeD3Node<T> | null {
|
||||
const selection = d3
|
||||
.select<HTMLElement, TreeD3Node<T>>(this.containerElement)
|
||||
.select(`.node[data-id="${id}"]`);
|
||||
if (selection.empty()) {
|
||||
return null;
|
||||
}
|
||||
return selection.datum();
|
||||
}
|
||||
|
||||
override cleanup(): void {
|
||||
super.cleanup();
|
||||
d3.select(this.graphElement).selectAll('*').remove();
|
||||
}
|
||||
|
||||
override render(graph: T): void {
|
||||
// cleanup old graph
|
||||
this.cleanup();
|
||||
|
||||
const data = d3.hierarchy(graph, (node: T) => node.children as Iterable<T>);
|
||||
const tree = d3.tree<T>();
|
||||
const svg = d3.select(this.containerElement);
|
||||
const g = d3.select<HTMLElement, TreeD3Node<T>>(this.graphElement);
|
||||
|
||||
this.zoomController = d3.zoom<HTMLElement, unknown>().scaleExtent([0.1, 2]);
|
||||
this.zoomController.on('start zoom end', (e: {transform: number}) => {
|
||||
g.attr('transform', e.transform);
|
||||
});
|
||||
svg.call(this.zoomController);
|
||||
|
||||
// Compute the new tree layout.
|
||||
tree.nodeSize(this.config.nodeSize);
|
||||
tree.separation((a: TreeD3Node<T>, b: TreeD3Node<T>) => {
|
||||
return this.config.nodeSeparation(a, b);
|
||||
});
|
||||
|
||||
const nodes = tree(data);
|
||||
this.root = nodes;
|
||||
|
||||
arrowDefId++;
|
||||
svg
|
||||
.append('svg:defs')
|
||||
.selectAll('marker')
|
||||
.data([`end${arrowDefId}`]) // Different link/path types can be defined here
|
||||
.enter()
|
||||
.append('svg:marker') // This section adds in the arrows
|
||||
.attr('id', String)
|
||||
.attr('viewBox', '0 -5 10 10')
|
||||
.attr('refX', 10)
|
||||
.attr('refY', 0)
|
||||
.attr('class', 'arrow')
|
||||
.attr('markerWidth', 6)
|
||||
.attr('markerHeight', 6)
|
||||
.attr('orient', 'auto')
|
||||
.append('svg:path')
|
||||
.attr('d', 'M0,-5L10,0L0,5');
|
||||
|
||||
const [labelWidth, labelHeight] = this.config.nodeLabelSize;
|
||||
const halfLabelWidth = labelWidth / 2;
|
||||
const halfLabelHeight = labelHeight / 2;
|
||||
|
||||
const d3Link = g
|
||||
.selectAll('.link')
|
||||
.data(nodes.descendants().slice(1))
|
||||
.enter()
|
||||
.append('path')
|
||||
.attr('class', 'link')
|
||||
.attr('marker-end', `url(#end${arrowDefId})`)
|
||||
.attr('d', (node: TreeD3Node<T>) => {
|
||||
const {x, y} = this.getNodeCoor(node);
|
||||
const {x: parentX, y: parentY} = this.getNodeCoor(node.parent!);
|
||||
|
||||
if (this.config.orientation === 'horizontal') {
|
||||
return `
|
||||
M${x - halfLabelWidth},${y}
|
||||
C${(x + parentX) / 2},
|
||||
${y} ${(x + parentX) / 2},
|
||||
${parentY} ${parentX + halfLabelWidth},
|
||||
${parentY}`;
|
||||
}
|
||||
|
||||
return `
|
||||
M${x},${y - halfLabelHeight}
|
||||
C${x},
|
||||
${(y + parentY) / 2} ${parentX},
|
||||
${(y + parentY) / 2} ${parentX},
|
||||
${parentY + halfLabelHeight}`;
|
||||
});
|
||||
|
||||
this.config.d3LinkModifier(d3Link);
|
||||
|
||||
// Declare the nodes
|
||||
const d3Node = g
|
||||
.selectAll('g.node-group')
|
||||
.data(nodes.descendants())
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'node-group')
|
||||
.on('click', (pointerEvent: PointerEvent, node: TreeD3Node<T>) => {
|
||||
this.nodeClickListeners.forEach((listener) => listener(pointerEvent, node));
|
||||
})
|
||||
.on('mouseover', (pointerEvent: PointerEvent, node: TreeD3Node<T>) => {
|
||||
this.nodeMouseoverListeners.forEach((listener) => listener(pointerEvent, node));
|
||||
})
|
||||
.on('mouseout', (pointerEvent: PointerEvent, node: TreeD3Node<T>) => {
|
||||
this.nodeMouseoutListeners.forEach((listener) => listener(pointerEvent, node));
|
||||
})
|
||||
.attr('transform', (node: TreeD3Node<T>) => {
|
||||
const {x, y} = this.getNodeCoor(node);
|
||||
return `translate(${x},${y})`;
|
||||
})
|
||||
.append('foreignObject')
|
||||
.attr('width', labelWidth)
|
||||
.attr('height', labelHeight)
|
||||
.attr('x', -halfLabelWidth)
|
||||
.attr('y', -halfLabelHeight)
|
||||
.append('xhtml:div')
|
||||
.attr('class', 'node')
|
||||
.attr('title', (node: TreeD3Node<T>) => {
|
||||
return node.data.label;
|
||||
})
|
||||
.html((node: TreeD3Node<T>) => {
|
||||
const label = node.data.label;
|
||||
return label.length > MAX_NODE_LABEL_LENGTH
|
||||
? label.slice(0, MAX_NODE_LABEL_LENGTH - '...'.length) + '...'
|
||||
: label;
|
||||
});
|
||||
|
||||
this.config.d3NodeModifier(d3Node);
|
||||
|
||||
svg.attr('height', '100%').attr('width', '100%');
|
||||
}
|
||||
|
||||
/** Returns the node coordinates based on orientation. */
|
||||
private getNodeCoor(node: TreeD3Node<T>): {x: number; y: number} {
|
||||
const {x, y} = node;
|
||||
|
||||
if (this.config.orientation === 'horizontal') {
|
||||
return {
|
||||
x: y,
|
||||
y: x,
|
||||
};
|
||||
}
|
||||
return {x, y};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue