mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-31 21:17:47 +00:00
Improve network graph layout
This commit is contained in:
parent
c13a80ce5e
commit
d963f6ee78
@ -30,6 +30,8 @@ export interface NetworkNode {
|
|||||||
* Distance from the center, where 0 is the center and 1 is the edge
|
* Distance from the center, where 0 is the center and 1 is the edge
|
||||||
*/
|
*/
|
||||||
polarDistance?: number;
|
polarDistance?: number;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetworkLink {
|
export interface NetworkLink {
|
||||||
@ -74,7 +76,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
|||||||
|
|
||||||
@state() private _reducedMotion = false;
|
@state() private _reducedMotion = false;
|
||||||
|
|
||||||
@state() private _physicsEnabled = true;
|
@state() private _physicsEnabled = false;
|
||||||
|
|
||||||
@state() private _showLabels = true;
|
@state() private _showLabels = true;
|
||||||
|
|
||||||
@ -174,33 +176,51 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
|||||||
reducedMotion: boolean,
|
reducedMotion: boolean,
|
||||||
showLabels: boolean,
|
showLabels: boolean,
|
||||||
isMobile: boolean
|
isMobile: boolean
|
||||||
) => {
|
) => ({
|
||||||
const containerWidth = this.clientWidth;
|
id: "network",
|
||||||
const containerHeight = this.clientHeight;
|
type: "graph",
|
||||||
return {
|
layout: physicsEnabled ? "force" : "none",
|
||||||
id: "network",
|
draggable: true,
|
||||||
type: "graph",
|
roam: true,
|
||||||
layout: physicsEnabled ? "force" : "none",
|
selectedMode: "single",
|
||||||
draggable: true,
|
label: {
|
||||||
roam: true,
|
show: showLabels,
|
||||||
selectedMode: "single",
|
position: "right",
|
||||||
label: {
|
},
|
||||||
show: showLabels,
|
emphasis: {
|
||||||
position: "right",
|
focus: isMobile ? "none" : "adjacency",
|
||||||
},
|
},
|
||||||
emphasis: {
|
force: {
|
||||||
focus: isMobile ? "none" : "adjacency",
|
repulsion: [400, 600],
|
||||||
},
|
edgeLength: [200, 350],
|
||||||
force: {
|
gravity: 0.05,
|
||||||
repulsion: [400, 600],
|
layoutAnimation: !reducedMotion && data.nodes.length < 100,
|
||||||
edgeLength: [200, 300],
|
},
|
||||||
gravity: 0.1,
|
edgeSymbol: ["none", "arrow"],
|
||||||
layoutAnimation: !reducedMotion && data.nodes.length < 100,
|
edgeSymbolSize: 10,
|
||||||
},
|
data: this._getSeriesData(data.nodes, data.links, this._nodePositions),
|
||||||
edgeSymbol: ["none", "arrow"],
|
links: data.links.map((link) => ({
|
||||||
edgeSymbolSize: 10,
|
...link,
|
||||||
data: data.nodes.map((node) => {
|
value: link.reverseValue
|
||||||
const echartsNode: NonNullable<GraphSeriesOption["data"]>[number] = {
|
? 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,
|
id: node.id,
|
||||||
name: node.name,
|
name: node.name,
|
||||||
category: node.category,
|
category: node.category,
|
||||||
@ -209,38 +229,111 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
|||||||
symbol: node.symbol || "circle",
|
symbol: node.symbol || "circle",
|
||||||
itemStyle: node.itemStyle || {},
|
itemStyle: node.itemStyle || {},
|
||||||
fixed: node.fixed,
|
fixed: node.fixed,
|
||||||
};
|
x: node.x,
|
||||||
if (this._nodePositions[node.id]) {
|
y: node.y,
|
||||||
echartsNode.x = this._nodePositions[node.id].x;
|
}) as NonNullable<GraphSeriesOption["data"]>[number]
|
||||||
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 || [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
deepEqual
|
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() {
|
private _togglePhysics() {
|
||||||
this._saveNodePositions();
|
this._saveNodePositions();
|
||||||
this._physicsEnabled = !this._physicsEnabled;
|
this._physicsEnabled = !this._physicsEnabled;
|
||||||
@ -251,6 +344,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _saveNodePositions() {
|
private _saveNodePositions() {
|
||||||
|
const positions = {};
|
||||||
if (this._baseChart?.chart) {
|
if (this._baseChart?.chart) {
|
||||||
this._baseChart.chart
|
this._baseChart.chart
|
||||||
// @ts-ignore private method but no other way to get the graph positions
|
// @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) => {
|
.eachNode((node: any) => {
|
||||||
const layout = node.getLayout();
|
const layout = node.getLayout();
|
||||||
if (layout) {
|
if (layout) {
|
||||||
this._nodePositions[node.id] = {
|
positions[node.id] = {
|
||||||
x: layout[0],
|
x: layout[0],
|
||||||
y: layout[1],
|
y: layout[1],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
this._nodePositions = positions;
|
||||||
}
|
}
|
||||||
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
|
Loading…
x
Reference in New Issue
Block a user