/
githubmirror
/
angular
Обзор
Документация
Войти
/
githubmirror
/
angular
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
devtools/projects/ng-devtools/src/lib/shared/signals-visualizer/signals-visualizer.ts
802 строки
24 KB
hawkgs
refactor(devtools): format code
20 июл 2026, 11:48
20 июл 2026, 11:48
2aecfb0
Код
Авторство
О чём код?
/** * @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 {graphlib, render as dagreRender} from 'dagre-d3-es'; import { isClusterNode, isSignalNode, DevtoolsSignalGraph, DevtoolsSignalGraphNode, DevtoolsClusterNodeType, DevtoolsSignalNode, getNodeLabel, } from '../signal-graph'; import {DebugSignalGraphNode} from '../../../../../protocol'; import type {DagreCluster, DagreEdge, DagreNode, DagreRegularNode} from './visualizer-types'; export type DependenciesHighlightEvent = | { node: DevtoolsSignalGraphNode; direction: 'up' | 'down'; state: 'highlighted'; } | {state: 'unhighlighted'}; type D3Selection = d3.Selection<d3.BaseType, unknown, null, undefined>; const KIND_CLASS_MAP: {[key in DebugSignalGraphNode['kind']]: string} = { 'signal': 'kind-signal', 'computed': 'kind-computed', 'effect': 'kind-effect', 'afterRenderEffectPhase': 'kind-effect', 'template': 'kind-template', 'linkedSignal': 'kind-linked-signal', 'childSignalProp': 'kind-child-signal-prop', 'unknown': 'kind-unknown', }; const CLUSTER_TYPE_CLASS_MAP: {[key in DevtoolsClusterNodeType]: string} = { 'resource': 'kind-resource', }; const CLUSTER_CHILD_TYPE_CLASS_MAP: {[key in DevtoolsClusterNodeType]: string} = { 'resource': 'resource-child', }; const NODE_CLASS = 'node'; const NODE_CONT_CLASS = 'node-cont'; const SPECIAL_NODE_CLASS = 'special'; const NODE_HEADER_CLASS = 'header'; const NODE_BODY_CLASS = 'body'; const CLUSTER_CLASS = 'cluster'; const EDGE_CLASS = 'edge'; const CLUSTER_EDGE_CLASS = 'cluster-edge'; const CLUSTER_CHILD_CLASS = 'cluster-child'; const CLOSE_BTN_CLASS = 'cluster-close-btn'; const NODE_EPOCH_UPDATE_ANIM_CLASS = 'node-epoch-anim'; const HIGHLIGHTED_NODE_CLASS = 'highlighted'; const HIGHLIGHTED_DEP_CLASS = 'highlighted-dep'; const HIGHLIGHTED_DEP_ORIGIN_CLASS = 'highlighted-dep-origin'; // Keep in sync with signals-visualizer.component.scss const NODE_WIDTH = 100; const NODE_HEIGHT = 60; const NODE_EPOCH_UPDATE_ANIM_DURATION = 250; const TEMPL_NODE_ZOOM_SCALE = 0.8; const CLUSTER_EXPAND_ANIM_DURATION = 1100; // Empirical value based on Dagre's behavior with an included leeway // Terminology: // // - `DevtoolsSignalGraph` – The input graph that the visualizer accepts // - `DevtoolsSignalGraphNode` – A node of the input graph // - Standard signal node – A visualized standard node // - Standard cluster node – A cluster node, visualized as a standard node (i.e. collapsed) // - Expanded cluster node – A cluster node, visualized as a container of its child nodes (i.e. expanded) export class SignalsGraphVisualizer { private graph: graphlib.Graph<any, DagreNode, DagreEdge>; private drender: ReturnType<typeof dagreRender>; zoomController: d3.ZoomBehavior<SVGSVGElement, unknown>; private animatedNodesMap: Map<string, number> = new Map(); private timeouts: Set<ReturnType<typeof setTimeout>> = new Set(); private nodeClickListeners: ((node: DevtoolsSignalGraphNode) => void)[] = []; private clustersStateChangeListeners: ((expandedClustersIds: Set<string>) => void)[] = []; private dependenciesHighlightListeners: ((e: DependenciesHighlightEvent) => void)[] = []; private expandedClustersIds = new Set<string>(); private inputGraph: DevtoolsSignalGraph | null = null; private highlightedPath: { originNode: D3Selection | null; pathNodes: D3Selection[]; edges: D3Selection[]; } = { originNode: null, pathNodes: [], edges: [], }; constructor(private svg: SVGSVGElement) { this.graph = new graphlib.Graph({directed: true, compound: true}); this.graph.setGraph({}); this.graph.graph().rankdir = 'TB'; this.graph.graph().ranksep = 50; this.graph.graph().nodesep = 15; this.graph.setDefaultEdgeLabel(() => ({})); this.drender = dagreRender(); const d3svg = d3.select(this.svg); d3svg.attr('height', '100%').attr('width', '100%'); const g = d3svg.append('g'); this.zoomController = d3.zoom<SVGSVGElement, unknown>().scaleExtent([0.1, 2]); this.zoomController.on('start zoom end', (e: {transform: number}) => { g.attr('transform', e.transform); }); d3svg.call(this.zoomController); } /** Snaps to a provided node. */ snapToNode(nodeId: string): void; snapToNode(node: DevtoolsSignalGraphNode): void; snapToNode(node: string | DevtoolsSignalGraphNode) { const nodeId = typeof node === 'string' ? node : node.id; const dagreNode = this.graph.node(nodeId); if (!dagreNode) { return; } const contWidth = this.svg.clientWidth; const contHeight = this.svg.clientHeight; const x = contWidth / 2 - dagreNode.x! * TEMPL_NODE_ZOOM_SCALE; const y = contHeight / 2 - dagreNode.y! * TEMPL_NODE_ZOOM_SCALE; d3.select(this.svg) .transition() .duration(500) .call( this.zoomController.transform, d3.zoomIdentity.translate(x, y).scale(TEMPL_NODE_ZOOM_SCALE), ); } /** Snaps to the root node – either the template or the first node depending which exists. */ snapToRootNode() { const node = this.inputGraph?.nodes.find((n) => isSignalNode(n) && n.kind === 'template') ?? this.inputGraph?.nodes[0]; if (node) { this.snapToNode(node); } } /** * Adds a standard outline to the provided node. * Using `null`, clears the highlighted node. */ highlightNode(nodeId: string | null) { d3.select(this.svg) .select('.output .nodes') .selectAll<SVGGElement, string>(`.${NODE_CLASS}`) .classed(HIGHLIGHTED_NODE_CLASS, (d) => d === nodeId); } zoomScale(scale: number) { if (this.zoomController) { const svg = d3.select(this.svg); this.zoomController.scaleTo(svg, scale); } } cleanup(): void { for (const timeout of this.timeouts) { clearTimeout(timeout); } this.timeouts.clear(); } reset() { for (const node of this.graph.nodes()) { this.graph.removeNode(node); } for (const {v, w} of this.graph.edges()) { this.graph.removeEdge(v, w, undefined); } this.animatedNodesMap.clear(); this.expandedClustersIds.clear(); this.unhighlightDependencies(); this.notifyForClusterVisibilityUpdate(); this.cleanup(); this.timeouts.clear(); } render(signalGraph: DevtoolsSignalGraph): void { this.updateClusters(signalGraph); this.updateNodes(signalGraph); this.updateEdges(signalGraph); const g = d3.select(this.svg).select('g'); this.drender(g, this.graph); this.addCloseButtonsToClusters(g); this.reinforceNodeDimensions(); this.inputGraph = signalGraph; } setClusterState(clusterId: string, expanded: boolean) { if (!this.inputGraph) { return; } if (expanded) { this.expandedClustersIds.add(clusterId); } else { this.expandedClustersIds.delete(clusterId); } this.notifyForClusterVisibilityUpdate(); this.unhighlightDependencies(); this.render(this.inputGraph); } highlightDependencies(node: DevtoolsSignalGraphNode, direction: 'down' | 'up') { if (!this.inputGraph) { return; } const nodeIdx = this.inputGraph.nodes.findIndex((n) => node === n); if (nodeIdx === -1) { return; } this.unhighlightNodesAndEdges(); const pathNodesIds = new Set<string>(); const edges = new Set<string>(); const stack = [nodeIdx]; const nodes = this.inputGraph.nodes; while (stack.length) { const currentIdx = stack.pop()!; for (const edge of this.inputGraph.edges) { const currNodeIdx = direction === 'down' ? edge.producer : edge.consumer; if (currNodeIdx === currentIdx) { const nextNodeIdx = direction === 'down' ? edge.consumer : edge.producer; pathNodesIds.add(nodes[nextNodeIdx].id); edges.add(`${nodes[edge.producer].id}-${nodes[edge.consumer].id}`); stack.push(nextNodeIdx); } } } this.highlightEdges(edges); this.highlightNodesPath(node.id, pathNodesIds); for (const cb of this.dependenciesHighlightListeners) { cb({ node, direction, state: 'highlighted', }); } } /** Unhighlight the currently highlighted dependencies. */ unhighlightDependencies() { const originNode = this.unhighlightNodesAndEdges(); if (originNode) { for (const cb of this.dependenciesHighlightListeners) { cb({ state: 'unhighlighted', }); } } } /** * Listen for node clicks. * * @param cb Callback/listener * @returns An unlisten function */ onNodeClick(cb: (node: DevtoolsSignalGraphNode) => void): () => void { this.nodeClickListeners.push(cb); return () => { const idx = this.nodeClickListeners.indexOf(cb); if (idx > -1) { this.nodeClickListeners.splice(idx, 1); } }; } /** * Listen for cluster state changes. * * @param cb Callback/listener * @returns An unlisten function */ onClustersStateChange(cb: (expandedClustersIds: Set<string>) => void): () => void { this.clustersStateChangeListeners.push(cb); return () => { const idx = this.clustersStateChangeListeners.indexOf(cb); if (idx > -1) { this.clustersStateChangeListeners.splice(idx, 1); } }; } /** * Listen for dependencies highlighting. * * @param cb Callback/listener * @returns An unlisten function */ onDependenciesHighlight(cb: (e: DependenciesHighlightEvent) => void): () => void { this.dependenciesHighlightListeners.push(cb); return () => { const idx = this.dependenciesHighlightListeners.indexOf(cb); if (idx > -1) { this.dependenciesHighlightListeners.splice(idx, 1); } }; } private isNodeVisible(node: DevtoolsSignalGraphNode): boolean { // Checks whether it's a: // 1. Standard signal node that's not part of a cluster // 2. Standard signal node that's part of an expanded cluster // 3. Standard cluster node that represents a currently collapsed cluster return ( (isSignalNode(node) && (!node.clusterId || this.expandedClustersIds.has(node.clusterId))) || (isClusterNode(node) && !this.expandedClustersIds.has(node.id)) ); } private updateClusters(signalGraph: DevtoolsSignalGraph) { const newClusterIds = new Set<string>(); for (const clusterId of Object.keys(signalGraph.clusters)) { newClusterIds.add(clusterId); const currentNode = this.graph.node(clusterId); const isClusterNode = isDagreClusterNode(currentNode); const isExpanded = this.expandedClustersIds.has(clusterId); if (isExpanded && !isClusterNode) { // Render the new cluster as an expanded cluster node this.graph.setNode(clusterId, { label: signalGraph.clusters[clusterId].name, class: CLUSTER_CLASS, clusterLabelPos: 'top', }); } else if (!isExpanded && isClusterNode) { // Collapse collapsed clusters this.graph.removeNode(clusterId); } } let clustersSetUpdated = false; // Remove expanded cluster nodes that are not longer present in the graph for (const clusterId of this.expandedClustersIds) { if (!newClusterIds.has(clusterId)) { this.expandedClustersIds.delete(clusterId); this.graph.removeNode(clusterId); clustersSetUpdated = true; } } if (clustersSetUpdated) { this.notifyForClusterVisibilityUpdate(); } } private updateNodes(signalGraph: DevtoolsSignalGraph) { let matchedNodeId = false; const signalNodes = convertNodesToMap(signalGraph.nodes); // Remove old & rendundant nodes for (const oldNodeId of this.graph.nodes()) { const node = signalNodes.get(oldNodeId); // To avoid removing an expanded cluster node, we have to check if `isSignalNode`. if (!node || (!this.isNodeVisible(node) && isSignalNode(node))) { this.graph.removeNode(oldNodeId); this.animatedNodesMap.delete(oldNodeId); } else { matchedNodeId = true; } } const updatedNodes: string[] = []; // Add new/update existing nodes for (const n of signalGraph.nodes) { const isSignal = isSignalNode(n); const existingNode = this.graph.node(n.id) as DagreRegularNode | undefined; if (existingNode) { if (isSignal && n.epoch !== existingNode.epoch) { this.updateDagreNode(n, existingNode, signalGraph); updatedNodes.push(n.id); } else if (isClusterNode(n) && !this.expandedClustersIds.has(n.id)) { const previewNode = signalGraph.nodes[n.previewNode ?? -1] as DevtoolsSignalNode | undefined; if (previewNode && previewNode.epoch !== existingNode.epoch) { this.updateDagreNode(previewNode, existingNode, signalGraph); updatedNodes.push(n.id); } } } else if (this.isNodeVisible(n)) { this.graph.setNode(n.id, { label: this.createNodeHtml(n, signalGraph), labelType: 'html', shape: 'rect', padding: 0, style: 'fill: none;', epoch: isSignal ? n.epoch : isClusterNode(n) && n.previewNode ? (signalGraph.nodes[n.previewNode] as DevtoolsSignalNode).epoch : undefined, width: NODE_WIDTH, height: NODE_HEIGHT, }); // Add to the expanded cluster node, if the node is part of a visible cluster if (isSignal && this.expandedClustersIds.has(n.clusterId || '')) { this.graph.setParent(n.id, n.clusterId); } } } const epochAnimTimeout = setTimeout(() => { this.timeouts.delete(epochAnimTimeout); this.updateNodeEpochAnimations(updatedNodes); }, NODE_EPOCH_UPDATE_ANIM_DURATION); this.timeouts.add(epochAnimTimeout); const clusterChildrenAnimTimeout = setTimeout(() => { this.timeouts.delete(clusterChildrenAnimTimeout); this.getNodeLabelsSelection().attr('style', 'animation-name: unset; opacity: 1'); }, CLUSTER_EXPAND_ANIM_DURATION); this.timeouts.add(clusterChildrenAnimTimeout); if (matchedNodeId) { this.graph.graph().transition = (selection: any) => { return selection.transition().duration(500); }; } else { this.graph.graph().transition = undefined; } } private updateEdges(signalGraph: DevtoolsSignalGraph) { const newEdgeIds = new Set(); for (const edge of signalGraph.edges) { const producerNode = signalGraph.nodes[edge.producer]; const consumerNode = signalGraph.nodes[edge.consumer]; const producerId = producerNode.id; const consumerId = consumerNode.id; const edgeId = getEdgeId(producerId, consumerId); newEdgeIds.add(edgeId); if ( !this.graph.hasEdge(producerId, consumerId, undefined) && this.isNodeVisible(producerNode) && this.isNodeVisible(consumerNode) ) { const isClusterEdge = signalGraph.nodes.some( (node) => isSignalNode(node) && node.clusterId && (node.id === producerId || node.id === consumerId), ); const classes = EDGE_CLASS + (isClusterEdge ? ` ${CLUSTER_EDGE_CLASS}` : ''); this.graph.setEdge(producerId, consumerId, { curve: d3.curveBasis, class: classes, }); } } const signalNodes = convertNodesToMap(signalGraph.nodes); for (const edge of this.graph.edges()) { const edgeId = getEdgeId(edge.v, edge.w); if ( !newEdgeIds.has(edgeId) || !this.isNodeVisible(signalNodes.get(edge.v)!) || !this.isNodeVisible(signalNodes.get(edge.w)!) ) { this.graph.removeEdge(edge.v, edge.w, undefined); } } } private updateDagreNode( newNode: DevtoolsSignalNode, existingNode: DagreRegularNode, signalGraph: DevtoolsSignalGraph, ) { const count = this.animatedNodesMap.get(newNode.id) ?? 0; this.animatedNodesMap.set(newNode.id, count + 1); existingNode.epoch = newNode.epoch; d3.select(existingNode.label).classed(NODE_EPOCH_UPDATE_ANIM_CLASS, true); const body = existingNode.label.getElementsByClassName(NODE_BODY_CLASS).item(0); if (body) { body.textContent = getBodyText(newNode, signalGraph); } } private updateNodeEpochAnimations(updatedNodes: string[]) { for (const id of updatedNodes) { const count = this.animatedNodesMap.get(id) ?? 0; if (count > 0) { this.animatedNodesMap.set(id, count - 1); } } this.getNodeLabelsSelection() .filter((d) => !this.animatedNodesMap.get(d)) .classed(NODE_EPOCH_UPDATE_ANIM_CLASS, false); } private notifyForClusterVisibilityUpdate() { for (const cb of this.clustersStateChangeListeners) { cb(new Set(this.expandedClustersIds)); } } private createNodeHtml( node: DevtoolsSignalGraphNode, graph: DevtoolsSignalGraph, ): HTMLDivElement { const outer = document.createElement('div'); outer.onclick = () => { for (const cb of this.nodeClickListeners) { cb(node); } }; const typeClass = isSignalNode(node) ? KIND_CLASS_MAP[node.kind] : CLUSTER_TYPE_CLASS_MAP[node.clusterType]; outer.className = `${NODE_CONT_CLASS} ${typeClass}`; const header = document.createElement('div'); if (isSignalNode(node)) { if (!node.label) { header.classList.add(SPECIAL_NODE_CLASS); } if (node.clusterId) { outer.classList.add(CLUSTER_CHILD_CLASS); const clusterType = graph.clusters[node.clusterId].type; outer.classList.add(CLUSTER_CHILD_TYPE_CLASS_MAP[clusterType]); } } header.classList.add(NODE_HEADER_CLASS); header.textContent = getNodeLabel(node); const body = document.createElement('div'); body.className = NODE_BODY_CLASS; body.textContent = getBodyText(node, graph); outer.appendChild(header); outer.appendChild(body); return outer; } // A customization of the Dagre D3 cluster. // Needs to be executed after the cluster has been rendered. private addCloseButtonsToClusters(g: d3.Selection<d3.BaseType, unknown, null, unknown>) { const iconMargin = 7; const iconSize = 15; const clusters = g.selectAll(`.${CLUSTER_CLASS}`); // We need to wait until the cluster animation completes before calling `getBBox`. const timeout = setTimeout(() => { this.timeouts.delete(timeout); clusters.each((clusterId, idx, groups) => { const svgCluster = groups[idx] as SVGGraphicsElement; const d3Cluster = d3.select(svgCluster); d3Cluster.select(`.${CLOSE_BTN_CLASS}`).remove(); const {width, height} = svgCluster.getBBox(); const group = d3Cluster .append('g') .attr('transform', `translate(${width / 2 - iconMargin}, ${-(height / 2 - iconMargin)})`) .attr('class', CLOSE_BTN_CLASS); group .append('path') .attr('transform', 'translate(-18, 18) scale(0.022)') .attr( 'd', 'm256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z', ); group .append('rect') .attr('width', iconSize) .attr('height', iconSize) .attr('x', -iconSize) .on('click', () => { this.setClusterState(clusterId as string, false); }); // Accessibility group.append('title').attr('id', `cluster-${clusterId}`).text('Close the cluster'); }); }, CLUSTER_EXPAND_ANIM_DURATION); this.timeouts.add(timeout); } // For some reason Dagre is not using the statically-provided node width and height // for the <foreignObject> that contains the HTML label. This method ensures that // this always happens, which prevents misaligned nodes after a cluster collapse. private reinforceNodeDimensions() { d3.select(this.svg) .selectAll(`.${NODE_CLASS} .label`) .each((nodeId, idx, group) => { const node = group[idx]; const d3Node = d3.select(node); const {width, height} = this.graph.node(nodeId as string) as DagreRegularNode; d3Node .select('g') .attr('transform', `translate(${-width / 2}, ${-height / 2})`) .select('foreignObject') .attr('width', width) .attr('height', height); }); } private getNodeLabelsSelection() { return d3 .select(this.svg) .select('.output .nodes') .selectAll<SVGGElement, string>(`.${NODE_CLASS}`) .select(`.label foreignObject .${NODE_CONT_CLASS}`); } /** Highlight a set of edges represented by `v-w` strings. */ private highlightEdges(edges: Set<string>) { d3.select(this.svg) .selectAll(`.${EDGE_CLASS}`) .each((edge, idx, group) => { const {v, w} = edge as {v: number; w: number}; const edgeId = v + '-' + w; if (edges.has(edgeId)) { const edgeSelector = group[idx]; const edgeSelection = d3.select(edgeSelector); this.highlightedPath.edges.push(edgeSelection); appendClasses(edgeSelection, HIGHLIGHTED_DEP_CLASS); } }); } /** * Highlights a path of nodes (usually a dependency tree) * by a provided origin node and path nodes ID. */ private highlightNodesPath(originNodeId: string, pathNodesIds: Set<string>) { d3.select(this.svg) .selectAll(`.${NODE_CLASS}`) .each((nodeId, idx, group) => { const isOriginNode = nodeId === originNodeId; if (isOriginNode || pathNodesIds.has(nodeId as string)) { const nodeSelector = group[idx]; const nodeSelection = d3.select(nodeSelector); let className: string; if (isOriginNode) { className = HIGHLIGHTED_DEP_ORIGIN_CLASS; this.highlightedPath.originNode = nodeSelection; } else { className = HIGHLIGHTED_DEP_CLASS; this.highlightedPath.pathNodes.push(nodeSelection); } appendClasses(nodeSelection, className); } }); } /** Unhighlight the currently highlighted nodes and edges. Returns the origin node. */ private unhighlightNodesAndEdges() { const {originNode, pathNodes, edges} = this.highlightedPath; if (originNode) { removeClasses(originNode, HIGHLIGHTED_DEP_ORIGIN_CLASS); } for (const node of pathNodes) { removeClasses(node, HIGHLIGHTED_DEP_CLASS); } for (const edge of edges) { removeClasses(edge, HIGHLIGHTED_DEP_CLASS); } this.highlightedPath = { originNode: null, pathNodes: [], edges: [], }; return originNode; } } function getBodyText(node: DevtoolsSignalGraphNode, graph: DevtoolsSignalGraph): string { if (isClusterNode(node)) { const previewNode = graph.nodes[node.previewNode ?? -1] as DevtoolsSignalNode | undefined; if (previewNode) { return previewNode.preview.preview; } return '[nodes]'; } if ( node.kind === 'signal' || node.kind === 'computed' || node.kind === 'linkedSignal' || node.kind === 'childSignalProp' ) { return node.preview.preview; } if (node.kind === 'template') { return '</>'; } if (node.kind === 'effect' || node.kind === 'afterRenderEffectPhase') { return '() => {}'; } return ''; } function getEdgeId(producerId: string, consumerId: string): string { return `${btoa(producerId)}-${btoa(consumerId)}`; } function convertNodesToMap(nodes: DevtoolsSignalGraphNode[]): Map<string, DevtoolsSignalGraphNode> { return new Map(nodes.map((n) => [n.id, n])); } function isDagreClusterNode(node: DagreNode | undefined): node is DagreCluster { return !!(node as DagreCluster)?.clusterLabelPos; } /** Appends space-separated classes to a D3 selection. */ function appendClasses(selection: D3Selection, classNames: string) { const currentClasses = selection.attr('class'); selection.attr('class', `${currentClasses} ${classNames}`); } /** Removes space-separated classes to a D3 selection. */ function removeClasses(selection: D3Selection, classNames: string) { const classesSet = new Set(classNames.split(' ').map((cl) => cl.trim())); const updatedClasses = selection .attr('class') .split(' ') .map((cl) => cl.trim()) .filter((cl) => !classesSet.has(cl)) .join(' '); selection.attr('class', updatedClasses); }