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:
hawkgs 2025-06-25 15:02:28 +03:00 committed by Jessica Janiuk
parent e63cd1f405
commit 3eec4badab
24 changed files with 2017 additions and 2085 deletions

View file

@ -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",
],
)

View file

@ -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};
}
}

View file

@ -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",
],
)

View file

@ -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';

View file

@ -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",

View file

@ -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;

View file

@ -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',

View file

@ -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",
],
)

View file

@ -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",
],
)

View file

@ -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;
});
}

View file

@ -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 {

View file

@ -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",

View file

@ -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);
});
});
});

View file

@ -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!;
}

View file

@ -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%');
}
}

View file

@ -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(' ');
});
}
}

View file

@ -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",
],
)

View file

@ -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);
}
}

View file

@ -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;
}
}
}

View file

@ -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};
}
}