Improve network graph layout

This commit is contained in:
Petar Petrov 2025-07-23 14:05:21 +03:00
parent c13a80ce5e
commit d963f6ee78

View File

@ -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<GraphSeriesOption["data"]>[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<string, { x: number; y: number }>
) =>
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<GraphSeriesOption["data"]>[number]
),
deepEqual
);
private _getPositionedNodes(
nodes: NetworkNode[],
links: NetworkLink[],
nodePositions: Record<string, { x: number; y: number }>
) {
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<string, { x: number; y: number }>,
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`