From d963f6ee783817445d80b3e69b251840870823de Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 23 Jul 2025 14:05:21 +0300 Subject: [PATCH] Improve network graph layout --- src/components/chart/ha-network-graph.ts | 211 ++++++++++++++++------- 1 file changed, 153 insertions(+), 58 deletions(-) diff --git a/src/components/chart/ha-network-graph.ts b/src/components/chart/ha-network-graph.ts index 2293b9fc02..738743105e 100644 --- a/src/components/chart/ha-network-graph.ts +++ b/src/components/chart/ha-network-graph.ts @@ -30,6 +30,8 @@ export interface NetworkNode { * Distance from the center, where 0 is the center and 1 is the edge */ polarDistance?: number; + x?: number; + y?: number; } export interface NetworkLink { @@ -74,7 +76,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) { @state() private _reducedMotion = false; - @state() private _physicsEnabled = true; + @state() private _physicsEnabled = false; @state() private _showLabels = true; @@ -174,33 +176,51 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) { reducedMotion: boolean, showLabels: boolean, isMobile: boolean - ) => { - const containerWidth = this.clientWidth; - const containerHeight = this.clientHeight; - return { - id: "network", - type: "graph", - layout: physicsEnabled ? "force" : "none", - draggable: true, - roam: true, - selectedMode: "single", - label: { - show: showLabels, - position: "right", - }, - emphasis: { - focus: isMobile ? "none" : "adjacency", - }, - force: { - repulsion: [400, 600], - edgeLength: [200, 300], - gravity: 0.1, - layoutAnimation: !reducedMotion && data.nodes.length < 100, - }, - edgeSymbol: ["none", "arrow"], - edgeSymbolSize: 10, - data: data.nodes.map((node) => { - const echartsNode: NonNullable[number] = { + ) => ({ + id: "network", + type: "graph", + layout: physicsEnabled ? "force" : "none", + draggable: true, + roam: true, + selectedMode: "single", + label: { + show: showLabels, + position: "right", + }, + emphasis: { + focus: isMobile ? "none" : "adjacency", + }, + force: { + repulsion: [400, 600], + edgeLength: [200, 350], + gravity: 0.05, + layoutAnimation: !reducedMotion && data.nodes.length < 100, + }, + edgeSymbol: ["none", "arrow"], + edgeSymbolSize: 10, + data: this._getSeriesData(data.nodes, data.links, this._nodePositions), + links: data.links.map((link) => ({ + ...link, + value: link.reverseValue + ? Math.max(link.value ?? 0, link.reverseValue) + : link.value, + // remove arrow for bidirectional links + symbolSize: link.reverseValue ? 1 : link.symbolSize, // 0 doesn't work + })), + categories: data.categories || [], + }), + deepEqual + ); + + private _getSeriesData = memoizeOne( + ( + nodes: NetworkNode[], + links: NetworkLink[], + nodePositions: Record + ) => + this._getPositionedNodes(nodes, links, nodePositions).map( + (node) => + ({ id: node.id, name: node.name, category: node.category, @@ -209,38 +229,111 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) { symbol: node.symbol || "circle", itemStyle: node.itemStyle || {}, fixed: node.fixed, - }; - if (this._nodePositions[node.id]) { - echartsNode.x = this._nodePositions[node.id].x; - echartsNode.y = this._nodePositions[node.id].y; - } else if (typeof node.polarDistance === "number") { - // set the position of the node at polarDistance from the center in a random direction - const angle = Math.random() * 2 * Math.PI; - echartsNode.x = - ((Math.cos(angle) * containerWidth) / 2) * node.polarDistance; - echartsNode.y = - ((Math.sin(angle) * containerHeight) / 2) * node.polarDistance; - this._nodePositions[node.id] = { - x: echartsNode.x, - y: echartsNode.y, - }; - } - return echartsNode; - }), - links: data.links.map((link) => ({ - ...link, - value: link.reverseValue - ? Math.max(link.value ?? 0, link.reverseValue) - : link.value, - // remove arrow for bidirectional links - symbolSize: link.reverseValue ? 1 : link.symbolSize, // 0 doesn't work - })), - categories: data.categories || [], - }; - }, + x: node.x, + y: node.y, + }) as NonNullable[number] + ), deepEqual ); + private _getPositionedNodes( + nodes: NetworkNode[], + links: NetworkLink[], + nodePositions: Record + ) { + const containerWidth = this.clientWidth; + const containerHeight = this.clientHeight; + const positionedNodes: NetworkNode[] = nodes.map((node) => ({ ...node })); + positionedNodes.forEach((node) => { + if (nodePositions[node.id]) { + node.x = nodePositions[node.id].x; + node.y = nodePositions[node.id].y; + } + if (node.polarDistance === 0) { + if (node.x == null && node.y == null) { + node.x = containerWidth / 2; + node.y = containerHeight / 2; + } + this._positionNodeNeighbors( + node, + positionedNodes, + links, + nodePositions + ); + } + }); + positionedNodes.forEach((node) => { + // set positions for unconnected nodes + if (node.polarDistance && node.x == null && node.y == null) { + // set the position of the node at polarDistance from the center in a random direction + const angle = Math.random() * 2 * Math.PI; + node.x = + ((Math.cos(angle) * containerWidth) / 2) * node.polarDistance + + containerWidth / 2; + node.y = + ((Math.sin(angle) * containerHeight) / 2) * node.polarDistance + + containerHeight / 2; + // save the random position + this._nodePositions[node.id] = { + x: node.x, + y: node.y, + }; + } + }); + return positionedNodes; + } + + private _positionNodeNeighbors( + node: NetworkNode, + nodes: NetworkNode[], + links: NetworkLink[], + nodePositions: Record, + parentId?: string, + minAngle = 0, + maxAngle = Math.PI * 2 + ) { + const neighbors = links + .map((l) => + l.source === node.id && l.target !== parentId && !l.ignoreForceLayout + ? nodes.find((n) => n.id === l.target) + : l.target === node.id && + l.source !== parentId && + !l.ignoreForceLayout + ? nodes.find((n) => n.id === l.source) + : null + ) + .filter(Boolean) as NetworkNode[]; + if (!neighbors.length) { + return; + } + const angle = Math.abs(maxAngle - minAngle) / neighbors.length; + const toContinue: { neighbor: NetworkNode; angle: number }[] = []; + neighbors.forEach((neighbor, i) => { + if (neighbor.x == null && neighbor.y == null) { + const nodeAngle = minAngle + angle * i + angle / 2; + toContinue.push({ neighbor, angle: nodeAngle }); + if (nodePositions[neighbor.id]) { + neighbor.x = nodePositions[neighbor.id].x; + neighbor.y = nodePositions[neighbor.id].y; + } else { + neighbor.x = node.x! + (Math.cos(nodeAngle) * this.clientWidth) / 4; + neighbor.y = node.y! + (Math.sin(nodeAngle) * this.clientHeight) / 4; + } + } + }); + toContinue.forEach(({ neighbor, angle: neighborAngle }) => { + this._positionNodeNeighbors( + neighbor, + nodes, + links, + nodePositions, + node.id, + neighborAngle - Math.PI / 2, + neighborAngle + Math.PI / 2 + ); + }); + } + private _togglePhysics() { this._saveNodePositions(); this._physicsEnabled = !this._physicsEnabled; @@ -251,6 +344,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) { } private _saveNodePositions() { + const positions = {}; if (this._baseChart?.chart) { this._baseChart.chart // @ts-ignore private method but no other way to get the graph positions @@ -260,13 +354,14 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) { .eachNode((node: any) => { const layout = node.getLayout(); if (layout) { - this._nodePositions[node.id] = { + positions[node.id] = { x: layout[0], y: layout[1], }; } }); } + this._nodePositions = positions; } static styles = css`