Echarts network graph for ZHA (#25457)

* Echarts network graph for ZHA

* improve layout

* better diff

* remove vis-network

* not bad layout

* fix LQI and clean up a bit

* Use ha-chart-base and remove header

* legend

* use color vars

* use colorVariables

* fix

* add physics toggle

* tweak lines

* remove vis-network

* minor tweaks

* dynamically load graph chart

* type fix

* fix height

* navigate to device page on label click

* PR comments

* aria tweak

* make extraComponents non reactive

* PR comments

* quick fix

* just make hass non reactive

* button tweak

* Update src/components/chart/ha-network-graph.ts

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>

---------

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
This commit is contained in:
Petar Petrov 2025-05-19 16:37:05 +03:00 committed by GitHub
parent 4b72a6029c
commit c4e391c264
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 641 additions and 499 deletions

View File

@ -137,7 +137,6 @@
"tinykeys": "3.0.0",
"ua-parser-js": "2.0.3",
"vis-data": "7.1.9",
"vis-network": "9.1.9",
"vue": "2.7.16",
"vue2-daterange-picker": "0.6.8",
"weekstart": "2.0.0",

View File

@ -48,7 +48,8 @@ export class HaChartBase extends LitElement {
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@property({ attribute: false }) public extraComponents?: any[];
// extraComponents is not reactive and should not trigger updates
public extraComponents?: any[];
@state()
@consume({ context: themesContext, subscribe: true })
@ -106,6 +107,7 @@ export class HaChartBase extends LitElement {
})
);
if (!this.options?.dataZoom) {
// Add keyboard event listeners
const handleKeyDown = (ev: KeyboardEvent) => {
if (
@ -141,7 +143,6 @@ export class HaChartBase extends LitElement {
});
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
this._listeners.push(
@ -149,6 +150,7 @@ export class HaChartBase extends LitElement {
() => window.removeEventListener("keyup", handleKeyUp)
);
}
}
protected firstUpdated() {
this._setupChart();
@ -191,6 +193,7 @@ export class HaChartBase extends LitElement {
<div class="chart"></div>
</div>
${this._renderLegend()}
<div class="chart-controls">
${this._isZoomed
? html`<ha-icon-button
class="zoom-reset"
@ -201,6 +204,8 @@ export class HaChartBase extends LitElement {
)}
></ha-icon-button>`
: nothing}
<slot name="button"></slot>
</div>
</div>
`;
}
@ -210,7 +215,7 @@ export class HaChartBase extends LitElement {
return nothing;
}
const legend = ensureArray(this.options.legend)[0] as LegendComponentOption;
if (!legend.show) {
if (!legend.show || legend.type !== "custom") {
return nothing;
}
const datasets = ensureArray(this.data);
@ -315,7 +320,9 @@ export class HaChartBase extends LitElement {
this.chart.on("click", (e: ECElementEvent) => {
fireEvent(this, "chart-click", e);
});
if (!this.options?.dataZoom) {
this.chart.getZr().on("dblclick", this._handleClickZoom);
}
if (this._isTouchDevice) {
this.chart.getZr().on("click", (e: ECElementEvent) => {
if (!e.zrByTouch) {
@ -410,6 +417,12 @@ export class HaChartBase extends LitElement {
} as XAXisOption;
});
}
let legend = this.options?.legend;
if (legend) {
legend = ensureArray(legend).map((l) =>
l.type === "custom" ? { show: false } : l
);
}
const options = {
animation: !this._reducedMotion,
darkMode: this._themes.darkMode ?? false,
@ -424,7 +437,7 @@ export class HaChartBase extends LitElement {
iconStyle: { opacity: 0 },
},
...this.options,
legend: { show: false },
legend,
xAxis,
};
@ -725,16 +738,26 @@ export class HaChartBase extends LitElement {
height: 100%;
width: 100%;
}
.zoom-reset {
.chart-controls {
position: absolute;
top: 16px;
right: 4px;
display: flex;
flex-direction: column;
gap: 4px;
}
.chart-controls ha-icon-button,
.chart-controls ::slotted(ha-icon-button) {
background: var(--card-background-color);
border-radius: 4px;
--mdc-icon-button-size: 32px;
color: var(--primary-color);
border: 1px solid var(--divider-color);
}
.chart-controls ha-icon-button.inactive,
.chart-controls ::slotted(ha-icon-button.inactive) {
color: var(--state-inactive-color);
}
.chart-legend {
max-height: 60%;
overflow-y: auto;

View File

@ -0,0 +1,278 @@
import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
import type { TopLevelFormatterParams } from "echarts/types/dist/shared";
import { mdiGoogleCirclesGroup } from "@mdi/js";
import memoizeOne from "memoize-one";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { ECOption } from "../../resources/echarts";
import "./ha-chart-base";
import type { HaChartBase } from "./ha-chart-base";
import type { HomeAssistant } from "../../types";
export interface NetworkNode {
id: string;
name?: string;
category?: number;
label?: string;
value?: number;
symbolSize?: number;
symbol?: string;
itemStyle?: {
color?: string;
borderColor?: string;
borderWidth?: number;
};
fixed?: boolean;
/**
* Distance from the center, where 0 is the center and 1 is the edge
*/
polarDistance?: number;
}
export interface NetworkLink {
source: string;
target: string;
value?: number;
reverseValue?: number;
lineStyle?: {
width?: number;
color?: string;
type?: "solid" | "dashed" | "dotted";
};
symbolSize?: number | number[];
label?: {
show?: boolean;
formatter?: string;
};
ignoreForceLayout?: boolean;
}
export interface NetworkData {
nodes: NetworkNode[];
links: NetworkLink[];
categories?: { name: string }[];
}
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let GraphChart: typeof import("echarts/lib/chart/graph/install");
@customElement("ha-network-graph")
export class HaNetworkGraph extends LitElement {
public chart?: EChartsType;
@property({ attribute: false }) public data?: NetworkData;
@property({ attribute: false }) public tooltipFormatter?: (
params: TopLevelFormatterParams
) => string;
public hass!: HomeAssistant;
@state() private _reducedMotion = false;
@state() private _physicsEnabled = true;
private _listeners: (() => void)[] = [];
private _nodePositions: Record<string, { x: number; y: number }> = {};
@query("ha-chart-base") private _baseChart?: HaChartBase;
constructor() {
super();
if (!GraphChart) {
import("echarts/lib/chart/graph/install").then((module) => {
GraphChart = module;
this.requestUpdate();
});
}
}
public async connectedCallback() {
super.connectedCallback();
this._listeners.push(
listenMediaQuery("(prefers-reduced-motion)", (matches) => {
if (this._reducedMotion !== matches) {
this._reducedMotion = matches;
}
})
);
}
public disconnectedCallback() {
super.disconnectedCallback();
while (this._listeners.length) {
this._listeners.pop()!();
}
}
protected render() {
if (!GraphChart) {
return nothing;
}
return html`<ha-chart-base
.hass=${this.hass}
.data=${this._getSeries(
this.data,
this._physicsEnabled,
this._reducedMotion
)}
.options=${this._createOptions(this.data?.categories)}
height="100%"
.extraComponents=${[GraphChart]}
>
<slot name="button" slot="button"></slot>
<ha-icon-button
slot="button"
class=${this._physicsEnabled ? "active" : "inactive"}
.path=${mdiGoogleCirclesGroup}
@click=${this._togglePhysics}
label=${this.hass.localize(
"ui.panel.config.common.graph.toggle_physics"
)}
></ha-icon-button>
</ha-chart-base>`;
}
private _createOptions = memoizeOne(
(categories?: NetworkData["categories"]): ECOption => ({
tooltip: {
trigger: "item",
confine: true,
formatter: this.tooltipFormatter,
},
legend: {
show: !!categories?.length,
data: categories,
top: 8,
},
dataZoom: {
type: "inside",
filterMode: "none",
},
})
);
private _getSeries = memoizeOne(
(data?: NetworkData, physicsEnabled?: boolean, reducedMotion?: boolean) => {
if (!data) {
return [];
}
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: true,
position: "right",
},
emphasis: {
focus: "adjacency",
},
force: {
repulsion: [400, 600],
edgeLength: [200, 300],
gravity: 0.1,
layoutAnimation: !reducedMotion,
},
edgeSymbol: ["none", "arrow"],
edgeSymbolSize: 10,
data: data.nodes.map((node) => {
const echartsNode: NonNullable<GraphSeriesOption["data"]>[number] =
{
id: node.id,
name: node.name,
category: node.category,
value: node.value,
symbolSize: node.symbolSize || 30,
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 =
containerWidth / 2 +
((Math.cos(angle) * containerWidth) / 2) * node.polarDistance;
echartsNode.y =
containerHeight / 2 +
((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 || [],
},
] as any;
}
);
private _togglePhysics() {
if (this._baseChart?.chart) {
this._baseChart.chart
// @ts-ignore private method but no other way to get the graph positions
.getModel()
.getSeriesByIndex(0)
.getGraph()
.eachNode((node: any) => {
const layout = node.getLayout();
if (layout) {
this._nodePositions[node.id] = {
x: layout[0],
y: layout[1],
};
}
});
}
this._physicsEnabled = !this._physicsEnabled;
}
static styles = css`
:host {
display: block;
position: relative;
}
ha-chart-base {
height: 100%;
--chart-max-height: 100%;
}
ha-icon-button,
::slotted(ha-icon-button) {
margin-right: 12px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-network-graph": HaNetworkGraph;
}
interface HASSDomEvents {
"node-selected": { id: string };
}
}

View File

@ -287,6 +287,7 @@ export class StateHistoryChartLine extends LitElement {
},
} as YAXisOption,
legend: {
type: "custom",
show: this.showNames,
},
grid: {

View File

@ -308,6 +308,7 @@ export class StatisticsChart extends LitElement {
},
},
legend: {
type: "custom",
show: !this.hideLegend,
data: this._legendData,
},

View File

@ -1,27 +1,26 @@
import "@material/mwc-button";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { Edge, EdgeOptions, Node } from "vis-network/peer/esm/vis-network";
import { Network } from "vis-network/peer/esm/vis-network";
import { navigate } from "../../../../../common/navigate";
import "../../../../../components/search-input";
import "../../../../../components/device/ha-device-picker";
import "../../../../../components/ha-button-menu";
import "../../../../../components/ha-checkbox";
import type { HaCheckbox } from "../../../../../components/ha-checkbox";
import "../../../../../components/ha-formfield";
import type { DeviceRegistryEntry } from "../../../../../data/device_registry";
import { customElement, property, state } from "lit/decorators";
import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import { mdiRefresh } from "@mdi/js";
import "../../../../../components/chart/ha-network-graph";
import type {
NetworkData,
NetworkNode,
NetworkLink,
} from "../../../../../components/chart/ha-network-graph";
import type { ZHADevice } from "../../../../../data/zha";
import { fetchDevices, refreshTopology } from "../../../../../data/zha";
import "../../../../../layouts/hass-tabs-subpage";
import type {
ValueChangedEvent,
HomeAssistant,
Route,
} from "../../../../../types";
import type { HomeAssistant, Route } from "../../../../../types";
import { formatAsPaddedHex } from "./functions";
import { zhaTabs } from "./zha-config-dashboard";
import { colorVariables } from "../../../../../resources/theme/color.globals";
import { navigate } from "../../../../../common/navigate";
@customElement("zha-network-visualization-page")
export class ZHANetworkVisualizationPage extends LitElement {
@ -33,103 +32,18 @@ export class ZHANetworkVisualizationPage extends LitElement {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false })
public zoomedDeviceIdFromURL?: string;
@state()
private _networkData?: NetworkData;
@state()
private zoomedDeviceId?: string;
@query("#visualization", true)
private _visualization?: HTMLElement;
@state()
private _devices = new Map<string, ZHADevice>();
@state()
private _devicesByDeviceId = new Map<string, ZHADevice>();
@state()
private _nodes: Node[] = [];
@state()
private _network?: Network;
@state()
private _filter?: string;
private _autoZoom = true;
private _enablePhysics = true;
private _devices: ZHADevice[] = [];
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
// prevent zoomedDeviceIdFromURL from being restored to zoomedDeviceId after the user clears it
if (this.zoomedDeviceIdFromURL) {
this.zoomedDeviceId = this.zoomedDeviceIdFromURL;
}
if (this.hass) {
this._fetchData();
}
this._network = new Network(
this._visualization!,
{},
{
autoResize: true,
layout: {
improvedLayout: true,
},
physics: {
barnesHut: {
springConstant: 0,
avoidOverlap: 10,
damping: 0.09,
},
},
nodes: {
font: {
multi: "html",
},
},
edges: {
smooth: {
enabled: true,
type: "continuous",
forceDirection: "none",
roundness: 0.6,
},
},
}
);
this._network.on("doubleClick", (properties) => {
const ieee = properties.nodes[0];
if (ieee) {
const device = this._devices.get(ieee);
if (device) {
navigate(`/config/devices/device/${device.device_reg_id}`);
}
}
});
this._network.on("click", (properties) => {
const ieee = properties.nodes[0];
if (ieee) {
const device = this._devices.get(ieee);
if (device && this._autoZoom) {
this.zoomedDeviceId = device.device_reg_id;
this._zoomToDevice();
}
}
});
this._network.on("stabilized", () => {
if (this.zoomedDeviceId) {
this._zoomToDevice();
}
});
}
protected render() {
@ -144,359 +58,297 @@ export class ZHANetworkVisualizationPage extends LitElement {
"ui.panel.config.zha.visualization.header"
)}
>
${this.narrow
? html`
<div slot="header">
<search-input
<ha-network-graph
.hass=${this.hass}
class="header"
@value-changed=${this._handleSearchChange}
.filter=${this._filter}
.label=${this.hass.localize(
"ui.panel.config.zha.visualization.highlight_label"
)}
.data=${this._networkData}
.tooltipFormatter=${this._tooltipFormatter}
@chart-click=${this._handleChartClick}
>
</search-input>
</div>
`
: ""}
<div class="header">
${!this.narrow
? html`<search-input
.hass=${this.hass}
@value-changed=${this._handleSearchChange}
.filter=${this._filter}
.label=${this.hass.localize(
"ui.panel.config.zha.visualization.highlight_label"
)}
></search-input>`
: ""}
<ha-device-picker
.hass=${this.hass}
.value=${this.zoomedDeviceId}
.label=${this.hass.localize(
"ui.panel.config.zha.visualization.zoom_label"
)}
.deviceFilter=${this._filterDevices}
@value-changed=${this._onZoomToDevice}
></ha-device-picker>
<div class="controls">
<ha-formfield
.label=${this.hass!.localize(
"ui.panel.config.zha.visualization.auto_zoom"
)}
>
<ha-checkbox
@change=${this._handleAutoZoomCheckboxChange}
.checked=${this._autoZoom}
>
</ha-checkbox>
</ha-formfield>
<ha-formfield
.label=${this.hass!.localize(
"ui.panel.config.zha.visualization.enable_physics"
)}
><ha-checkbox
@change=${this._handlePhysicsCheckboxChange}
.checked=${this._enablePhysics}
>
</ha-checkbox
></ha-formfield>
<mwc-button @click=${this._refreshTopology}>
${this.hass!.localize(
<ha-icon-button
slot="button"
class="refresh-button"
.path=${mdiRefresh}
@click=${this._refreshTopology}
label=${this.hass.localize(
"ui.panel.config.zha.visualization.refresh_topology"
)}
</mwc-button>
</div>
</div>
<div id="visualization"></div>
></ha-icon-button>
</ha-network-graph>
</hass-tabs-subpage>
`;
}
private async _fetchData() {
const devices = await fetchDevices(this.hass!);
this._devices = new Map(
devices.map((device: ZHADevice) => [device.ieee, device])
);
this._devicesByDeviceId = new Map(
devices.map((device: ZHADevice) => [device.device_reg_id, device])
);
this._updateDevices(devices);
this._devices = await fetchDevices(this.hass!);
this._networkData = this._createChartData(this._devices);
}
private _updateDevices(devices: ZHADevice[]) {
this._nodes = [];
const edges: Edge[] = [];
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
const { dataType, data, name } = params as CallbackDataParams;
if (dataType === "edge") {
const { source, target, value } = data as any;
const targetName = this._networkData!.nodes.find(
(node) => node.id === target
)!.name;
const sourceName = this._networkData!.nodes.find(
(node) => node.id === source
)!.name;
const tooltipText = `${sourceName}${targetName}${value ? ` <b>LQI:</b> ${value}` : ""}`;
devices.forEach((device) => {
this._nodes.push({
id: device.ieee,
label: this._buildLabel(device),
shape: this._getShape(device),
mass: this._getMass(device),
fixed: device.device_type === "Coordinator",
color: {
background: device.available ? "#66FF99" : "#FF9999",
},
});
if (device.neighbors && device.neighbors.length > 0) {
device.neighbors.forEach((neighbor) => {
const idx = edges.findIndex(
(e) => device.ieee === e.to && neighbor.ieee === e.from
);
if (idx === -1) {
const edge_options = this._getEdgeOptions(parseInt(neighbor.lqi));
edges.push({
from: device.ieee,
to: neighbor.ieee,
label: neighbor.lqi + "",
color: edge_options.color,
width: edge_options.width,
length: edge_options.length,
physics: edge_options.physics,
arrows: {
from: {
enabled: neighbor.relationship !== "Child",
},
},
dashes: neighbor.relationship !== "Child",
});
} else {
const edge_options = this._getEdgeOptions(
Math.min(parseInt(edges[idx].label!), parseInt(neighbor.lqi))
);
edges[idx].label += " & " + neighbor.lqi;
edges[idx].color = edge_options.color;
edges[idx].width = edge_options.width;
edges[idx].length = edge_options.length;
edges[idx].physics = edge_options.physics;
delete edges[idx].arrows;
delete edges[idx].dashes;
const reverseValue = this._networkData!.links.find(
(link) => link.source === source && link.target === target
)?.reverseValue;
if (reverseValue) {
return `${tooltipText}<br>${targetName}${sourceName} <b>LQI:</b> ${reverseValue}`;
}
});
return tooltipText;
}
});
this._network?.setData({ nodes: this._nodes, edges: edges });
const device = this._devices.find((d) => d.ieee === (data as any).id);
if (!device) {
return name;
}
private _getEdgeOptions(lqi: number): EdgeOptions {
const length = 2000 - 4 * lqi;
if (lqi > 192) {
return {
color: { color: "#17ab00", highlight: "#17ab00" },
width: lqi / 20,
length: length,
physics: false,
};
}
if (lqi > 128) {
return {
color: { color: "#e6b402", highlight: "#e6b402" },
width: 9,
length: length,
physics: false,
};
}
return {
color: { color: "#bfbfbf", highlight: "#bfbfbf" },
width: 1,
length: length,
physics: false,
};
}
private _getMass(device: ZHADevice): number {
if (!device.available) {
return 6;
}
if (device.device_type === "Coordinator") {
return 2;
}
if (device.device_type === "Router") {
return 4;
}
return 5;
}
private _getShape(device: ZHADevice): string {
if (device.device_type === "Coordinator") {
return "box";
}
if (device.device_type === "Router") {
return "ellipse";
}
return "circle";
}
private _buildLabel(device: ZHADevice): string {
let label =
device.user_given_name !== null
? `<b>${device.user_given_name}</b>\n`
: "";
label += `<b>IEEE: </b>${device.ieee}`;
label += `\n<b>Device Type: </b>${device.device_type.replace("_", " ")}`;
let label = `<b>IEEE: </b>${device.ieee}`;
label += `<br><b>Device Type: </b>${device.device_type.replace("_", " ")}`;
if (device.nwk != null) {
label += `\n<b>NWK: </b>${formatAsPaddedHex(device.nwk)}`;
label += `<br><b>NWK: </b>${formatAsPaddedHex(device.nwk)}`;
}
if (device.manufacturer != null && device.model != null) {
label += `\n<b>Device: </b>${device.manufacturer} ${device.model}`;
label += `<br><b>Device: </b>${device.manufacturer} ${device.model}`;
} else {
label += "\n<b>Device is not in <i>'zigbee.db'</i></b>";
label += "<br><b>Device is not in <i>'zigbee.db'</i></b>";
}
if (device.area_id) {
label += `\n<b>Area ID: </b>${device.area_id}`;
const area = this.hass.areas[device.area_id];
if (area) {
label += `<br><b>Area: </b>${area.name}`;
}
}
return label;
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
const filterText = this._filter!.toLowerCase();
if (!this._network) {
return;
}
if (this._filter) {
const filteredNodeIds: (string | number)[] = [];
this._nodes.forEach((node) => {
if (node.label && node.label.toLowerCase().includes(filterText)) {
filteredNodeIds.push(node.id!);
}
});
this.zoomedDeviceId = "";
this._zoomOut();
this._network.selectNodes(filteredNodeIds, true);
} else {
this._network.unselectAll();
}
}
private _onZoomToDevice(event: ValueChangedEvent<string>) {
event.stopPropagation();
this.zoomedDeviceId = event.detail.value;
if (!this._network) {
return;
}
this._zoomToDevice();
}
private _zoomToDevice() {
this._filter = "";
if (!this.zoomedDeviceId) {
this._zoomOut();
} else {
const device: ZHADevice | undefined = this._devicesByDeviceId.get(
this.zoomedDeviceId
);
if (device) {
this._network!.fit({
nodes: [device.ieee],
animation: { duration: 500, easingFunction: "easeInQuad" },
});
}
}
}
private _zoomOut() {
this._network!.fit({
nodes: [],
animation: { duration: 500, easingFunction: "easeOutQuad" },
});
}
};
private async _refreshTopology(): Promise<void> {
await refreshTopology(this.hass);
await this._fetchData();
}
private _filterDevices = (device: DeviceRegistryEntry): boolean => {
if (!this.hass) {
return false;
}
for (const parts of device.identifiers) {
for (const part of parts) {
if (part === "zha") {
return true;
private _handleChartClick(e: CustomEvent): void {
if (
e.detail.dataType === "node" &&
e.detail.event.target.cursor === "pointer"
) {
const { id } = e.detail.data;
const device = this._devices.find((d) => d.ieee === id);
if (device) {
navigate(`/config/devices/device/${device.device_reg_id}`);
}
}
}
return false;
};
private _handleAutoZoomCheckboxChange(ev: Event) {
this._autoZoom = (ev.target as HaCheckbox).checked;
}
private _handlePhysicsCheckboxChange(ev: Event) {
this._enablePhysics = (ev.target as HaCheckbox).checked;
this._network!.setOptions(
this._enablePhysics
? { physics: { enabled: true } }
: { physics: { enabled: false } }
);
}
static get styles(): CSSResultGroup {
return [
css`
.header {
border-bottom: 1px solid var(--divider-color);
padding: 0 8px;
display: flex;
align-items: center;
justify-content: space-between;
height: var(--header-height);
box-sizing: border-box;
}
.header > * {
padding: 0 8px;
}
:host([narrow]) .header {
flex-direction: column;
align-items: stretch;
height: var(--header-height) * 2;
}
.search-toolbar {
display: flex;
align-items: center;
color: var(--secondary-text-color);
padding: 0 16px;
}
search-input {
flex: 1;
display: block;
}
search-input.header {
color: var(--secondary-text-color);
}
ha-device-picker {
flex: 1;
}
.controls {
display: flex;
align-items: center;
justify-content: space-between;
}
#visualization {
height: calc(100% - var(--header-height));
width: 100%;
}
:host([narrow]) #visualization {
height: calc(100% - (var(--header-height) * 2));
ha-network-graph {
height: 100%;
}
`,
];
}
private _createChartData(devices: ZHADevice[]): NetworkData {
const primaryColor = colorVariables["primary-color"];
const routerColor = colorVariables["cyan-color"];
const endDeviceColor = colorVariables["teal-color"];
const offlineColor = colorVariables["error-color"];
const nodes: NetworkNode[] = [];
const links: NetworkLink[] = [];
const categories = [
{
name: "Coordinator",
icon: "roundRect",
itemStyle: { color: primaryColor },
},
{ name: "Router", icon: "circle", itemStyle: { color: routerColor } },
{
name: "End Device",
icon: "circle",
itemStyle: { color: endDeviceColor },
},
{ name: "Offline", icon: "circle", itemStyle: { color: offlineColor } },
];
// Create all the nodes and links
devices.forEach((device) => {
const isCoordinator = device.device_type === "Coordinator";
let category: number;
if (!device.available) {
category = 3; // Offline
} else if (isCoordinator) {
category = 0;
} else if (device.device_type === "Router") {
category = 1;
} else {
category = 2; // End Device
}
// Create node
nodes.push({
id: device.ieee,
name: device.user_given_name || device.name || device.ieee,
category,
value: isCoordinator ? 3 : device.device_type === "Router" ? 2 : 1,
symbolSize: isCoordinator
? 40
: device.device_type === "Router"
? 30
: 20,
symbol: isCoordinator ? "roundRect" : "circle",
itemStyle: {
color: device.available
? isCoordinator
? primaryColor
: device.device_type === "Router"
? routerColor
: endDeviceColor
: offlineColor,
},
polarDistance: category === 0 ? 0 : category === 1 ? 0.5 : 0.9,
});
// Create links (edges)
const existingLinks = links.filter(
(link) => link.source === device.ieee || link.target === device.ieee
);
if (device.routes && device.routes.length > 0) {
device.routes.forEach((route) => {
const neighbor = device.neighbors.find(
(n) => n.nwk === route.next_hop
);
if (!neighbor) {
return;
}
const existingLink = existingLinks.find(
(link) =>
link.source === neighbor.ieee || link.target === neighbor.ieee
);
if (existingLink) {
if (existingLink.source === device.ieee) {
existingLink.value = Math.max(
existingLink.value!,
parseInt(neighbor.lqi)
);
} else {
existingLink.reverseValue = Math.max(
existingLink.reverseValue ?? 0,
parseInt(neighbor.lqi)
);
}
const width = this._getLQIWidth(parseInt(neighbor.lqi));
existingLink.symbolSize = (width / 4) * 6 + 3; // range 3-9
existingLink.lineStyle = {
...existingLink.lineStyle,
width,
color:
route.route_status === "Active"
? primaryColor
: existingLink.lineStyle!.color,
type: ["Child", "Parent"].includes(neighbor.relationship)
? "solid"
: existingLink.lineStyle!.type,
};
} else {
// Create a new link
const width = this._getLQIWidth(parseInt(neighbor.lqi));
const link: NetworkLink = {
source: device.ieee,
target: neighbor.ieee,
value: parseInt(neighbor.lqi),
lineStyle: {
width,
color:
route.route_status === "Active"
? primaryColor
: colorVariables["disabled-color"],
type: ["Child", "Parent"].includes(neighbor.relationship)
? "solid"
: "dotted",
},
symbolSize: (width / 4) * 6 + 3, // range 3-9
// By default, all links should be ignored for force layout
ignoreForceLayout: true,
};
links.push(link);
existingLinks.push(link);
}
});
} else if (existingLinks.length === 0) {
// If there are no links, create a link to the closest neighbor
const neighbors: { ieee: string; lqi: string }[] =
device.neighbors ?? [];
if (neighbors.length === 0) {
// If there are no neighbors, look for links from other devices
devices.forEach((d) => {
if (d.neighbors && d.neighbors.length > 0) {
const neighbor = d.neighbors.find((n) => n.ieee === device.ieee);
if (neighbor) {
neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi });
}
}
});
}
const closestNeighbor = neighbors.sort(
(a, b) => parseInt(b.lqi) - parseInt(a.lqi)
)[0];
if (closestNeighbor) {
links.push({
source: device.ieee,
target: closestNeighbor.ieee,
value: parseInt(closestNeighbor.lqi),
symbolSize: 5,
lineStyle: {
width: 1,
color: colorVariables["disabled-color"],
type: "dotted",
},
ignoreForceLayout: true,
});
}
}
});
// Now set ignoreForceLayout to false for the strongest connection of each device
// Except for the coordinator which can have multiple strong connections
devices.forEach((device) => {
if (device.device_type === "Coordinator") {
links.forEach((link) => {
if (link.source === device.ieee || link.target === device.ieee) {
link.ignoreForceLayout = false;
}
});
} else {
// Find the link that corresponds to this strongest connection
let strongestLink: NetworkLink | undefined;
links.forEach((link) => {
if (
(link.source === device.ieee || link.target === device.ieee) &&
link.value! > (strongestLink?.value ?? 0)
) {
strongestLink = link;
}
});
if (strongestLink) {
strongestLink.ignoreForceLayout = false;
}
}
});
return { nodes, links, categories };
}
private _getLQIWidth(lqi: number): number {
return Math.max(1, Math.floor((lqi / 256) * 4));
}
}
declare global {

View File

@ -184,13 +184,11 @@ export class HuiEnergyDevicesDetailGraphCard
...commonOptions,
legend: {
show: true,
type: "scroll",
animationDurationUpdate: 400,
type: "custom",
selected: this._hiddenStats.reduce((acc, stat) => {
acc[stat] = false;
return acc;
}, {}),
icon: "circle",
},
grid: {
top: 15,

View File

@ -29,6 +29,7 @@ import type {
LineSeriesOption,
CustomSeriesOption,
SankeySeriesOption,
GraphSeriesOption,
} from "echarts/charts";
import type {
// The component option types are defined with the ComponentOption suffix
@ -53,6 +54,7 @@ export type ECOption = ComposeOption<
| DataZoomComponentOption
| VisualMapComponentOption
| SankeySeriesOption
| GraphSeriesOption
>;
// Register the required components

View File

@ -355,6 +355,7 @@ const darkColorStyles = css`
}
`;
export const colorDerivedVariables = extractDerivedVars(colorStyles);
export const colorVariables = extractVars(colorStyles);
export const darkColorVariables = extractVars(darkColorStyles);
export const DefaultPrimaryColor = extractVar(colorStyles, "primary-color");

View File

@ -2113,7 +2113,10 @@
"learn_more": "Learn more",
"show_url": "Show full URL",
"hide_url": "Hide URL",
"copy_link": "Copy link"
"copy_link": "Copy link",
"graph": {
"toggle_physics": "Toggle physics"
}
},
"updates": {
"caption": "Updates",
@ -5696,10 +5699,6 @@
"visualization": {
"header": "Network visualization",
"caption": "Visualization",
"highlight_label": "Highlight devices",
"zoom_label": "Zoom to device",
"auto_zoom": "Auto zoom",
"enable_physics": "Enable physics",
"refresh_topology": "Refresh topology"
},
"device_binding": {

3
src/types/echarts.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
declare module "echarts/lib/chart/graph/install" {
export const install: EChartsExtensionInstaller;
}

View File

@ -9545,7 +9545,6 @@ __metadata:
typescript-eslint: "npm:8.32.1"
ua-parser-js: "npm:2.0.3"
vis-data: "npm:7.1.9"
vis-network: "npm:9.1.9"
vite-tsconfig-paths: "npm:5.1.4"
vitest: "npm:3.1.3"
vue: "npm:2.7.16"
@ -15123,20 +15122,6 @@ __metadata:
languageName: node
linkType: hard
"vis-network@npm:9.1.9":
version: 9.1.9
resolution: "vis-network@npm:9.1.9"
peerDependencies:
"@egjs/hammerjs": ^2.0.0
component-emitter: ^1.3.0
keycharm: ^0.2.0 || ^0.3.0 || ^0.4.0
uuid: ^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0
vis-data: ^6.3.0 || ^7.0.0
vis-util: ^5.0.1
checksum: 10/929b2645ff62645d030e6a03f2f618d0e66c9a83782c4f6f3257160d48ea49fc06eb62403762b010eb07d137bee72a1bd62e5c58ebbfb9091dbb3ca1f8f1887d
languageName: node
linkType: hard
"vite-node@npm:3.1.3":
version: 3.1.3
resolution: "vite-node@npm:3.1.3"