diff --git a/package.json b/package.json
index a6fc187da5..b851f202fd 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts
index b03f88847c..0703d66eb9 100644
--- a/src/components/chart/ha-chart-base.ts
+++ b/src/components/chart/ha-chart-base.ts
@@ -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,48 +107,49 @@ export class HaChartBase extends LitElement {
})
);
- // Add keyboard event listeners
- const handleKeyDown = (ev: KeyboardEvent) => {
- if (
- !this._modifierPressed &&
- ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
- ) {
- this._modifierPressed = true;
- if (!this.options?.dataZoom) {
- this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
+ if (!this.options?.dataZoom) {
+ // Add keyboard event listeners
+ const handleKeyDown = (ev: KeyboardEvent) => {
+ if (
+ !this._modifierPressed &&
+ ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
+ ) {
+ this._modifierPressed = true;
+ if (!this.options?.dataZoom) {
+ this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
+ }
+ // drag to zoom
+ this.chart?.dispatchAction({
+ type: "takeGlobalCursor",
+ key: "dataZoomSelect",
+ dataZoomSelectActive: true,
+ });
}
- // drag to zoom
- this.chart?.dispatchAction({
- type: "takeGlobalCursor",
- key: "dataZoomSelect",
- dataZoomSelectActive: true,
- });
- }
- };
+ };
- const handleKeyUp = (ev: KeyboardEvent) => {
- if (
- this._modifierPressed &&
- ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
- ) {
- this._modifierPressed = false;
- if (!this.options?.dataZoom) {
- this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
+ const handleKeyUp = (ev: KeyboardEvent) => {
+ if (
+ this._modifierPressed &&
+ ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
+ ) {
+ this._modifierPressed = false;
+ if (!this.options?.dataZoom) {
+ this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
+ }
+ this.chart?.dispatchAction({
+ type: "takeGlobalCursor",
+ key: "dataZoomSelect",
+ dataZoomSelectActive: false,
+ });
}
- this.chart?.dispatchAction({
- type: "takeGlobalCursor",
- key: "dataZoomSelect",
- dataZoomSelectActive: false,
- });
- }
- };
-
- window.addEventListener("keydown", handleKeyDown);
- window.addEventListener("keyup", handleKeyUp);
- this._listeners.push(
- () => window.removeEventListener("keydown", handleKeyDown),
- () => window.removeEventListener("keyup", handleKeyUp)
- );
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ window.addEventListener("keyup", handleKeyUp);
+ this._listeners.push(
+ () => window.removeEventListener("keydown", handleKeyDown),
+ () => window.removeEventListener("keyup", handleKeyUp)
+ );
+ }
}
protected firstUpdated() {
@@ -191,16 +193,19 @@ export class HaChartBase extends LitElement {
${this._renderLegend()}
- ${this._isZoomed
- ? html``
- : nothing}
+
+ ${this._isZoomed
+ ? html``
+ : nothing}
+
+
`;
}
@@ -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);
});
- this.chart.getZr().on("dblclick", this._handleClickZoom);
+ 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;
diff --git a/src/components/chart/ha-network-graph.ts b/src/components/chart/ha-network-graph.ts
new file mode 100644
index 0000000000..db4463ec41
--- /dev/null
+++ b/src/components/chart/ha-network-graph.ts
@@ -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 = {};
+
+ @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`
+
+
+ `;
+ }
+
+ 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[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 };
+ }
+}
diff --git a/src/components/chart/state-history-chart-line.ts b/src/components/chart/state-history-chart-line.ts
index 2ad664e627..5e11621b60 100644
--- a/src/components/chart/state-history-chart-line.ts
+++ b/src/components/chart/state-history-chart-line.ts
@@ -287,6 +287,7 @@ export class StateHistoryChartLine extends LitElement {
},
} as YAXisOption,
legend: {
+ type: "custom",
show: this.showNames,
},
grid: {
diff --git a/src/components/chart/statistics-chart.ts b/src/components/chart/statistics-chart.ts
index 57b66dac25..ea8469653a 100644
--- a/src/components/chart/statistics-chart.ts
+++ b/src/components/chart/statistics-chart.ts
@@ -308,6 +308,7 @@ export class StatisticsChart extends LitElement {
},
},
legend: {
+ type: "custom",
show: !this.hideLegend,
data: this._legendData,
},
diff --git a/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts b/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts
index f14e8f93b7..0ef102c6a0 100644
--- a/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts
+++ b/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts
@@ -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();
-
- @state()
- private _devicesByDeviceId = new Map();
-
- @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`
-
-
-
- `
- : ""}
-
-
+ >
+
`;
}
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 ? ` LQI: ${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}
${targetName} → ${sourceName} LQI: ${reverseValue}`;
}
- });
-
- this._network?.setData({ nodes: this._nodes, edges: edges });
- }
-
- 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,
- };
+ return tooltipText;
}
- if (lqi > 128) {
- return {
- color: { color: "#e6b402", highlight: "#e6b402" },
- width: 9,
- length: length,
- physics: false,
- };
+ const device = this._devices.find((d) => d.ieee === (data as any).id);
+ if (!device) {
+ return name;
}
- 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
- ? `${device.user_given_name}\n`
- : "";
- label += `IEEE: ${device.ieee}`;
- label += `\nDevice Type: ${device.device_type.replace("_", " ")}`;
+ let label = `IEEE: ${device.ieee}`;
+ label += `
Device Type: ${device.device_type.replace("_", " ")}`;
if (device.nwk != null) {
- label += `\nNWK: ${formatAsPaddedHex(device.nwk)}`;
+ label += `
NWK: ${formatAsPaddedHex(device.nwk)}`;
}
if (device.manufacturer != null && device.model != null) {
- label += `\nDevice: ${device.manufacturer} ${device.model}`;
+ label += `
Device: ${device.manufacturer} ${device.model}`;
} else {
- label += "\nDevice is not in 'zigbee.db'";
+ label += "
Device is not in 'zigbee.db'";
}
if (device.area_id) {
- label += `\nArea ID: ${device.area_id}`;
- }
- 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) {
- 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" },
- });
+ const area = this.hass.areas[device.area_id];
+ if (area) {
+ label += `
Area: ${area.name}`;
}
}
- }
-
- private _zoomOut() {
- this._network!.fit({
- nodes: [],
- animation: { duration: 500, easingFunction: "easeOutQuad" },
- });
- }
+ return label;
+ };
private async _refreshTopology(): Promise {
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 {
diff --git a/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts
index 46d39042ab..c3d9c5cc23 100644
--- a/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts
+++ b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts
@@ -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,
diff --git a/src/resources/echarts.ts b/src/resources/echarts.ts
index 2f5c27e483..cd0720352a 100644
--- a/src/resources/echarts.ts
+++ b/src/resources/echarts.ts
@@ -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
diff --git a/src/resources/theme/color.globals.ts b/src/resources/theme/color.globals.ts
index e2ef9db20e..e80932117e 100644
--- a/src/resources/theme/color.globals.ts
+++ b/src/resources/theme/color.globals.ts
@@ -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");
diff --git a/src/translations/en.json b/src/translations/en.json
index 59dcef401f..8d055f8420 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -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": {
diff --git a/src/types/echarts.d.ts b/src/types/echarts.d.ts
new file mode 100644
index 0000000000..408e810496
--- /dev/null
+++ b/src/types/echarts.d.ts
@@ -0,0 +1,3 @@
+declare module "echarts/lib/chart/graph/install" {
+ export const install: EChartsExtensionInstaller;
+}
diff --git a/yarn.lock b/yarn.lock
index 6938707c63..e51b8dea3d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"